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 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/.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/Makefile b/Makefile index c9c88f506..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 @@ -233,6 +235,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= [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="; 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/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 { 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/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/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/cmd/wire_gen.go b/cmd/wire_gen.go index d045c12ec..5b9fd648f 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" ) @@ -64,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() @@ -80,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 } @@ -101,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) @@ -170,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 } @@ -187,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 da549ce26..58239884a 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" ) @@ -69,14 +69,17 @@ type configOptions struct { MPVPath string MPVCmdTemplate string CoverArtPriority string - CoverJpegQuality int + CoverArtQuality int + EnableWebPEncoding bool ArtistArtPriority string + ArtistImageFolder string + DiscArtPriority string LyricsPriority string EnableGravatar bool EnableFavourites bool EnableStarRating bool EnableUserEditing bool - EnableCoverArtUpload bool + EnableArtworkUpload bool EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -85,6 +88,7 @@ type configOptions struct { DefaultLanguage string DefaultUIVolume int UISearchDebounceMs int + UICoverArtSize int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -104,7 +108,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 @@ -185,11 +188,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 @@ -261,6 +259,13 @@ type searchOptions struct { FullString bool } +// logFatal prints a fatal error message to stderr and exits. +// Overridden in tests to allow testing fatal paths. +var logFatal = func(args ...any) { + _, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...) + os.Exit(1) +} + var ( Server = &configOptions{} hooks []func() @@ -270,30 +275,29 @@ 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) } func Load(noConfigDump bool) { parseIniFileConfiguration() + 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") 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 == "" { @@ -301,14 +305,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 { @@ -317,8 +319,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) } } @@ -330,8 +331,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) } } @@ -339,10 +339,15 @@ 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") != "" { + // 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() } log.SetLevelString(Server.LogLevel) @@ -366,8 +371,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 = "" @@ -408,6 +412,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 @@ -415,6 +420,17 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") + + // 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 { @@ -440,6 +456,52 @@ 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) + } + } +} + +// 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) { + 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, + )) + 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) { @@ -457,18 +519,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) } } } @@ -478,7 +537,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 = "" @@ -547,15 +605,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 } @@ -598,6 +650,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) @@ -653,10 +720,14 @@ 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("coverjpegquality", 75) + 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") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) @@ -666,10 +737,11 @@ 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) - viper.SetDefault("enablecoverartupload", true) + viper.SetDefault("enableartworkupload", true) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) @@ -707,14 +779,12 @@ 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,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) @@ -736,6 +806,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) @@ -749,7 +820,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(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -801,8 +872,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 73fec4196..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() { @@ -108,6 +114,111 @@ 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() { + 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")) + }) + }) + + 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 d1d1bb3a9..051f9bb65 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 SetLogFatal(f func(...any)) func() { + old := logFatal + logFatal = f + return func() { logFatal = 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" diff --git a/consts/consts.go b/consts/consts.go index 2a5fdd94a..ff5dedc2b 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -70,7 +70,6 @@ const ( PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAvatar = "logo-192x192.png" - UICoverArtSize = 300 DefaultUIVolume = 100 DefaultUISearchDebounceMs = 200 @@ -85,6 +84,10 @@ const ( Zwsp = string('\u200b') ) +const ( + DefaultUICoverArtSize = 300 +) + // Prometheus options const ( PrometheusDefaultPath = "/metrics" @@ -103,6 +106,13 @@ const ( DefaultCacheCleanUpInterval = 10 * time.Minute ) +// Entity types +const ( + EntityArtist = "artist" + EntityPlaylist = "playlist" + EntityRadio = "radio" +) + const ( AlbumPlayCountModeAbsolute = "absolute" AlbumPlayCountModeNormalized = "normalized" @@ -153,7 +163,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/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/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/artwork.go b/core/artwork/artwork.go index 2e92b24c8..b8c395c12 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -122,6 +122,10 @@ 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) + case model.KindRadioArtwork: + artReader, err = newRadioArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index e2ea7adb0..380352d3f 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" @@ -25,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 @@ -41,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", @@ -190,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, @@ -233,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() { @@ -353,7 +380,7 @@ var _ = Describe("Artwork", func() { }) }) When("Square is false", func() { - It("returns a PNG 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()) @@ -364,7 +391,7 @@ var _ = Describe("Artwork", func() { 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 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()) @@ -380,9 +407,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 +426,97 @@ 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", "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{{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..23d5954df --- /dev/null +++ b/core/artwork/benchmark_pipeline_test.go @@ -0,0 +1,50 @@ +package artwork + +import ( + "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 := resizeStaticImage(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 := resizeStaticImage(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..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) { @@ -132,7 +133,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) } @@ -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() - r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true) + size := a.coverArtSize + r, _, err := a.artwork.Get(ctx, id, size, true) if err != nil { - return fmt.Errorf("caching id='%s': %w", id, err) + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) } - defer r.Close() _, err = io.Copy(io.Discard, r) - if err != nil { - return err - } - return nil + r.Close() + return err } func NoopCacheWarmer() CacheWarmer { diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index abf4f259a..a5da2004c 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "sync" "sync/atomic" "time" @@ -173,20 +174,42 @@ var _ = Describe("CacheWarmer", func() { 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 + 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) } diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 36b2fff05..641b12b33 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 { @@ -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 + hashInput } + hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( "%s.%x.%t", a.cacheKey.Key(), diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 9905039be..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,15 +72,26 @@ 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 } 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(), @@ -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/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/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/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 91d47b0b0..09707843d 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.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 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/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 6de983baf..85a19a4c3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -5,17 +5,36 @@ 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" ) +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) + }, +} + type resizedArtworkReader struct { artID model.ArtworkID cacheKey string @@ -46,7 +65,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 { @@ -61,7 +80,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 { @@ -75,11 +94,40 @@ 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, format, 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 + 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, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, 0, err } @@ -96,26 +144,43 @@ 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.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 new file mode 100644 index 000000000..7c14f5e44 --- /dev/null +++ b/core/artwork/reader_resized_test.go @@ -0,0 +1,176 @@ +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/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) diff --git a/core/external/provider.go b/core/external/provider.go index 6a4f4f5e0..40ca34069 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -374,13 +374,25 @@ 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() + 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) + } } - imageUrl := artist.ArtistImageUrl() if imageUrl == "" { return nil, model.ErrNotFound } 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 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/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d0cf0755d..33d6733c8 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "bytes" "context" "encoding/json" "errors" @@ -43,6 +44,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 +80,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 +242,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,18 +259,25 @@ 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 + stderr *bytes.Buffer } 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 + } + 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 @@ -262,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)) } @@ -271,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", @@ -283,7 +336,7 @@ var formatCodecMap = map[string]string{ var formatOutputMap = map[string]string{ "mp3": "mp3", "opus": "opus", - "aac": "ipod", + "aac": "adts", "flac": "flac", } @@ -339,11 +392,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..01b284172 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", "-", })) }) @@ -585,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() { @@ -605,6 +607,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/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/metrics/insights.go b/core/metrics/insights.go index f059d739a..f069d3fb6 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -193,13 +193,16 @@ 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.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 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..34648a49b 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -61,9 +61,12 @@ 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"` + 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/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/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/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 { 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/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.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..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() { @@ -125,6 +126,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) 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 = "" 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/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/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/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/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/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/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 } 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/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/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/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/go.mod b/go.mod index d50f738b7..fcee08c7e 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,12 @@ 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 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 +20,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 @@ -35,17 +34,14 @@ 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/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.34 + 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 @@ -62,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.38.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 ) @@ -84,12 +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.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 @@ -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 @@ -106,8 +104,9 @@ 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 github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -135,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 d92075234..e0671367a 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= @@ -44,8 +42,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 +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.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= @@ -69,6 +67,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= @@ -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= @@ -167,20 +167,18 @@ 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= 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= -github.com/mattn/go-sqlite3 v1.14.34/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= @@ -201,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= @@ -321,20 +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.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/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= 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= @@ -346,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= @@ -375,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= @@ -385,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= @@ -397,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= @@ -408,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= 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 7d294792b..2764d80e5 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]*\")[^\"]*", @@ -146,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) 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/artwork_id.go b/model/artwork_id.go index 36026dd03..1bd146c1f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -22,6 +22,8 @@ var ( KindArtistArtwork = Kind{"ar", "artist"} KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} + KindDiscArtwork = Kind{"dc", "disc"} + KindRadioArtwork = Kind{"ra", "radio"} ) var artworkKindMap = map[string]Kind{ @@ -29,6 +31,8 @@ var artworkKindMap = map[string]Kind{ KindArtistArtwork.prefix: KindArtistArtwork, KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, + KindDiscArtwork.prefix: KindDiscArtwork, + KindRadioArtwork.prefix: KindRadioArtwork, } type ArtworkID struct { @@ -91,6 +95,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, @@ -121,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/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/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)"}, 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/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/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/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() 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/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/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/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 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]) 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/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()) + }) + }) + }) }) 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", "!!!!!!!", ""), 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/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/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/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/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, #[serde(default)] + pub no_follow_redirects: bool, + #[serde(default)] #[serde(with = "base64_bytes")] pub body: Vec, #[serde(default)] diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index bce5a3a6e..7a0281f33 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,16 @@ "export": "Експорт", "makePublic": "Направи публичен", "makePrivate": "Направи личен", - "saveQueue": "", - "searchOrCreate": "", - "pressEnterToCreate": "", - "removeFromSelection": "" + "saveQueue": "Запазване на опашката в плейлист", + "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", + "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", + "removeFromSelection": "Премахване от селекцията" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", - "noPlaylistsFound": "", - "noPlaylists": "" + "noPlaylistsFound": "Няма намерени плейлисти", + "noPlaylists": "Няма налични плейлисти" } }, "radio": { @@ -263,7 +264,7 @@ "path": "Път", "size": "Размер", "updatedAt": "Изчезнал на", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "remove": "Премахни", @@ -275,134 +276,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 +589,9 @@ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", "remove_all_missing_title": "Премахни всички липсващи файлове", "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "noSimilarSongsFound": "", - "noTopSongsFound": "", - "startingInstantMix": "" + "noSimilarSongsFound": "Не са намерени подобни песни", + "noTopSongsFound": "Няма намерени топ песни", + "startingInstantMix": "Зареждане на незабавен микс..." }, "menu": { "library": "Библиотека", @@ -619,10 +622,10 @@ "playlists": "Плейлисти", "sharedPlaylists": "Споделени плейлисти", "librarySelector": { - "allLibraries": "", - "multipleLibraries": "", - "selectLibraries": "", - "none": "" + "allLibraries": "Всички библиотеки (%{count})", + "multipleLibraries": "%{selected} от %{total} библиотеки", + "selectLibraries": "Изберете библиотеки", + "none": "Няма" } }, "player": { @@ -655,7 +658,7 @@ "homepage": "Начална страница", "source": "Програмен код", "featureRequests": "Заявете функционалност", - "lastInsightsCollection": "", + "lastInsightsCollection": "Последна колекция от анализи", "insights": { "disabled": "Деактивиран", "waiting": "Изчакване" @@ -669,12 +672,13 @@ "configName": "Име на конфигурация", "environmentVariable": "Променлива на средата", "currentValue": "Текуща стойност", - "configurationFile": "", + "configurationFile": "Конфигурационен файл", "exportToml": "Експортиране на конфигурация (TOML)", "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML", "exportFailed": "Неуспешно копиране на конфигурация", - "devFlagsHeader": "", - "devFlagsComment": "" + "devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)", + "devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.", + "downloadToml": "Изтегляне на конфигурация (TOML)" } }, "activity": { @@ -687,7 +691,7 @@ "scanType": "Последно сканиране", "status": "Грешка при сканиране", "elapsedTime": "Изминало време", - "selectiveScan": "" + "selectiveScan": "Селективен" }, "help": { "title": "Бързи клавиши на Navidrome", @@ -704,8 +708,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..1ef2ce016 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", @@ -353,7 +354,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 +400,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 +677,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": { @@ -708,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 01d0856d6..edb1df183 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": "Disk %{discNumber}" }, "actions": { "addToQueue": "Afspil senere", @@ -353,7 +354,8 @@ "allUsers": "Tillad alle brugere", "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", - "selectedLibraries": "Valgte biblioteker" + "selectedLibraries": "Valgte biblioteker", + "allowWriteAccess": "Tillad skriveadgang" }, "sections": { "status": "Status", @@ -398,7 +400,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": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang." }, "placeholders": { "configKey": "nøgle", @@ -588,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", @@ -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..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", @@ -37,7 +37,8 @@ "sampleRate": "Samplerate", "missing": "Fehlend", "libraryName": "Bibliothek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Später abspielen", @@ -353,7 +354,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 +400,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", @@ -588,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", @@ -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..3876c2e33 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -37,7 +37,8 @@ "sampleRate": "Ποσοστό δειγματοληψίας", "missing": "Απών", "libraryName": "Βιβλιοθήκη", - "composer": "Συνθέτης" + "composer": "Συνθέτης", + "disc": "Δίσκος %{discNumber}" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -353,7 +354,8 @@ "allUsers": "Επιτρέψτε όλους τους χρήστες", "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", - "selectedLibraries": "Επιλεγμένες βιβλιοθήκες" + "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", + "allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής" }, "sections": { "status": "Κατάσταση", @@ -398,7 +400,8 @@ "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", - "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο." + "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." }, "placeholders": { "configKey": "κλειδί", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", - "startingInstantMix": "Φόρτωση Άμεσης Μίξης..." + "startingInstantMix": "Φόρτωση Άμεσης Μίξης...", + "uploadCover": "Μεταφόρτωση εξωφύλλου", + "removeCover": "Αφαίρεση καλύμματος", + "coverUploaded": "Το εξώφυλλο ενημερώθηκε", + "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", + "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" }, "menu": { "library": "Βιβλιοθήκη", @@ -674,7 +683,8 @@ "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", - "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις" + "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις", + "downloadToml": "Λήψη διαμόρφωσης (TOML)" } }, "activity": { 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/es.json b/resources/i18n/es.json index 38c1379c9..a018eda3d 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": "Disco %{discNumber}" }, "actions": { "addToQueue": "Reproducir después", @@ -353,7 +354,8 @@ "allUsers": "Permitir todos los usuarios", "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", - "selectedLibraries": "Bibliotecas seleccionadas" + "selectedLibraries": "Bibliotecas seleccionadas", + "allowWriteAccess": "Permitir acceso de escritura" }, "sections": { "status": "Estado", @@ -398,7 +400,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": "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", @@ -588,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", @@ -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/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)", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0d260fb44..bbad47bd6 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": "Levy %{discNumber}" }, "actions": { "addToQueue": "Lisää jonoon", @@ -353,7 +354,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 +400,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", @@ -588,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", @@ -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..dae9f47e7 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": "Disque %{discNumber}" }, "actions": { "addToQueue": "Ajouter à la file", @@ -353,7 +354,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": "Autoriser l'accès en écriture" }, "sections": { "status": "Statut", @@ -398,7 +400,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": "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", @@ -588,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", @@ -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..d62ca2ab2 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": "Disco %{discNumber}" }, "actions": { "addToQueue": "Ao final da cola", @@ -353,7 +354,8 @@ "allUsers": "Para todas as usuarias", "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Selecciona bibliotecas" + "selectedLibraries": "Selecciona bibliotecas", + "allowWriteAccess": "Conceder acceso de escritura" }, "sections": { "status": "Estado", @@ -398,7 +400,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": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." }, "placeholders": { "configKey": "clave", @@ -588,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", @@ -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/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.", 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": { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 6c9e154d1..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", @@ -218,15 +219,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", @@ -403,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", @@ -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/ru.json b/resources/i18n/ru.json index 5b20c3e19..78e7cfa26 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -37,7 +37,8 @@ "sampleRate": "Частота дискретизации (Hz)", "missing": "Поле отсутствует", "libraryName": "Библиотека", - "composer": "Композитор" + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "В очередь", @@ -353,7 +354,8 @@ "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки" + "selectedLibraries": "Избранные библиотеки", + "allowWriteAccess": "" }, "sections": { "status": "Статус", @@ -398,7 +400,8 @@ "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".", "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", - "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна." + "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ключ", @@ -674,7 +677,8 @@ "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", - "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях." + "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", + "downloadToml": "Скачать конфигурацию (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Ничего не играет", "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" } -} \ No newline at end of file +} 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 diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index f499d6ad5..ceb56e9b7 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": { @@ -353,7 +354,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 +399,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 +591,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 +677,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": { @@ -708,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 5896b4ed9..228cc2cf3 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": "Disc %{discNumber}" }, "actions": { "addToQueue": "Lägg till i kön", @@ -353,7 +354,8 @@ "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", - "selectedLibraries": "Valda bibliotek" + "selectedLibraries": "Valda bibliotek", + "allowWriteAccess": "Tillåt skrivrättigheter" }, "sections": { "status": "Status", @@ -398,7 +400,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": "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", @@ -588,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", @@ -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..b445d7464 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": { @@ -353,7 +354,8 @@ "allUsers": "อนุญาติผู้ใช้ทั้งหมด", "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", - "selectedLibraries": "ห้องสมุดเพลงถูกเลือก" + "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", + "allowWriteAccess": "" }, "sections": { "status": "สถานะ", @@ -398,7 +400,8 @@ "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด", "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", - "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน" + "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -588,7 +591,7 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "" + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." }, "menu": { "library": "ห้องสมุดเพลง", @@ -674,7 +677,8 @@ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", - "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง" + "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง", + "downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} \ 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 1bb59a8b1..93951a311 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": "光碟 %{discNumber}" }, "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,9 +214,9 @@ "selectPlaylist": "選取播放清單:", "addNewPlaylist": "建立「%{name}」", "export": "匯出", - "saveQueue": "將播放佇列儲存到播放清單", "makePublic": "設為公開", "makePrivate": "設為私人", + "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", "removeFromSelection": "移除選取項目" @@ -246,7 +247,6 @@ "username": "分享者", "url": "網址", "description": "描述", - "downloadable": "允許下載?", "contents": "內容", "expiresAt": "過期時間", "lastVisitedAt": "上次造訪時間", @@ -254,19 +254,17 @@ "format": "格式", "maxBitRate": "最大位元率", "updatedAt": "更新於", - "createdAt": "建立於" - }, - "notifications": {}, - "actions": {} + "createdAt": "建立於", + "downloadable": "允許下載?" + } }, "missing": { "name": "遺失檔案 |||| 遺失檔案", - "empty": "無遺失檔案", "fields": { "path": "路徑", "size": "檔案大小", - "libraryName": "媒體庫", - "updatedAt": "遺失於" + "updatedAt": "遺失於", + "libraryName": "媒體庫" }, "actions": { "remove": "刪除", @@ -274,7 +272,8 @@ }, "notifications": { "removed": "遺失檔案已刪除" - } + }, + "empty": "無遺失檔案" }, "library": { "name": "媒體庫 |||| 媒體庫", @@ -304,20 +303,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 +354,8 @@ "allUsers": "允許所有使用者", "selectedUsers": "選定的使用者", "allLibraries": "允許所有媒體庫", - "selectedLibraries": "選定的媒體庫" + "selectedLibraries": "選定的媒體庫", + "allowWriteAccess": "允許寫入權限" }, "sections": { "status": "狀態", @@ -389,8 +389,6 @@ }, "messages": { "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", - "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", @@ -400,7 +398,10 @@ "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", - "requiredHosts": "必要的 Hosts" + "requiredHosts": "必要的 Hosts", + "configValidationError": "設定驗證失敗:", + "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -443,7 +444,6 @@ "add": "加入", "back": "返回", "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "取消", "clear_input_value": "清除", "clone": "複製", @@ -467,6 +467,7 @@ "close_menu": "關閉選單", "unselect": "取消選取", "skip": "略過", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "分享", "download": "下載" }, @@ -558,48 +559,48 @@ "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": "正在載入即時混音...", + "uploadCover": "上傳封面", + "removeCover": "移除封面", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" }, "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 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 ] 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/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"}}} 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/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{ 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< [--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 [--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" diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index dc8b178d8..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. @@ -320,6 +331,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" } @@ -438,7 +453,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()) @@ -475,7 +490,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{}, @@ -485,7 +500,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_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/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() { 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/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/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/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.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..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 @@ -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("****")) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go new file mode 100644 index 000000000..1f55e3851 --- /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.EnableArtworkUpload && !user.IsAdmin { + http.Error(w, "artwork 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..669c4d7b5 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,11 +67,11 @@ 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) - 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/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..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) @@ -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/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/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/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/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/serve_index.go b/server/serve_index.go index 6b0c890a6..bd5be44f5 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -55,13 +55,14 @@ 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, "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/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/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() 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..4eb756b98 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())) }) }) @@ -292,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/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 { 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()) + }) + }) }) }) diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 9f2cd48f6..4fbd6a53d 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,19 @@ 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 + var coverArt string + if g.UploadedImage != "" { + coverArt = g.CoverArtID().String() + } + res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ + CoverArt: coverArt, + } } response := newResponse() @@ -103,7 +119,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/server/subsonic/radio_test.go b/server/subsonic/radio_test.go new file mode 100644 index 000000000..e959ebe29 --- /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("ra-rd-1_0")) + 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("ra-rd-1_0")) + }) + }) + + 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/.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 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/.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 be59e5851..f0bb26f66 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"` @@ -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"` @@ -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 { @@ -575,8 +580,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/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"}}, 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 792504871..4e494b324 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 { @@ -385,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 } 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 } 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/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 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/package-lock.json b/ui/package-lock.json index 04d6fe07c..2dd91a674 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", @@ -102,9 +102,9 @@ "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==", + "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", @@ -116,29 +116,30 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "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", @@ -164,13 +165,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "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" @@ -273,16 +274,16 @@ } }, "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==", + "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" @@ -470,25 +471,25 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "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" @@ -650,14 +651,14 @@ } }, "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==", + "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" @@ -829,9 +830,9 @@ } }, "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==", + "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", @@ -1031,15 +1032,15 @@ } }, "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==", + "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" @@ -1065,13 +1066,13 @@ } }, "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==", + "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" @@ -1287,9 +1288,9 @@ } }, "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==", + "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" @@ -1472,12 +1473,12 @@ } }, "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==", + "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", @@ -1491,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", @@ -1502,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", @@ -1515,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", @@ -1529,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", @@ -1542,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": { @@ -1579,22 +1580,22 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "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", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.6.tgz", - "integrity": "sha512-kz2fAQ5UzjV7X7D3ySxmj3vRq89dTpqOZWv76Z6pNPztkwb/0Yj1Mtx1xFrYj6mbIHysxtBot8J4o0JLCblcFw==", + "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" @@ -1615,17 +1616,17 @@ } }, "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==", + "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": { @@ -1633,9 +1634,9 @@ } }, "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==", + "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", @@ -1743,6 +1744,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1766,6 +1768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1795,9 +1798,9 @@ "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" ], @@ -1812,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" ], @@ -1829,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" ], @@ -1846,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" ], @@ -1863,9 +1866,9 @@ } }, "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==", + "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" ], @@ -1880,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" ], @@ -1897,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" ], @@ -1914,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" ], @@ -1931,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" ], @@ -1948,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" ], @@ -1965,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" ], @@ -1982,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" ], @@ -1999,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" ], @@ -2016,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" ], @@ -2033,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" ], @@ -2050,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" ], @@ -2067,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" ], @@ -2084,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" ], @@ -2101,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" ], @@ -2118,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" ], @@ -2135,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" ], @@ -2152,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" ], @@ -2169,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" ], @@ -2186,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" ], @@ -2203,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" ], @@ -2220,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" ], @@ -2290,9 +2293,9 @@ } }, "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==", + "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": { @@ -2301,9 +2304,9 @@ } }, "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==", + "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": { @@ -2340,9 +2343,9 @@ } }, "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==", + "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": { @@ -2351,9 +2354,9 @@ } }, "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==", + "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": { @@ -2385,115 +2388,13 @@ "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" - } - }, - "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" - }, - "engines": { - "node": "20 || >=22" - } - }, "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", - "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", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "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", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "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", - "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", - "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", - "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" - }, - "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", - "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", - "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": { @@ -2573,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", @@ -2626,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" @@ -2641,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", @@ -2687,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" }, @@ -2779,6 +2684,7 @@ "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", @@ -3048,9 +2954,9 @@ "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==", + "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" }, @@ -3149,9 +3055,9 @@ "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==", + "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" @@ -3410,6 +3316,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" }, @@ -3457,11 +3364,12 @@ "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==", + "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" } @@ -3479,10 +3387,11 @@ "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==", + "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", @@ -3652,6 +3561,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", @@ -3816,16 +3726,16 @@ "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==", + "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" }, @@ -3833,33 +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", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", - "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "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": { @@ -3868,31 +3778,31 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "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" }, @@ -3901,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": { @@ -3913,26 +3823,26 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "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": { @@ -3940,13 +3850,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "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" }, @@ -3955,9 +3866,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -3965,24 +3876,26 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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" }, @@ -4289,21 +4202,21 @@ "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==", + "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", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "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" }, @@ -4332,9 +4245,9 @@ } }, "node_modules/atomically": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", - "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", + "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", @@ -4375,9 +4288,9 @@ } }, "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==", + "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": { @@ -4395,13 +4308,13 @@ } }, "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==", + "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": { @@ -4418,25 +4331,25 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "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" @@ -4479,12 +4392,15 @@ "license": "MIT" }, "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==", + "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": { @@ -4598,12 +4514,12 @@ } }, "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==", + "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" @@ -4625,9 +4541,9 @@ } }, "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==", + "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" @@ -4646,9 +4562,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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", @@ -4664,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" @@ -4798,9 +4715,9 @@ } }, "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==", + "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", @@ -5039,6 +4956,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" @@ -5070,12 +4988,12 @@ "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==", + "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", @@ -5083,9 +5001,9 @@ } }, "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==", + "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", @@ -5503,13 +5421,10 @@ "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==", + "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" } @@ -5582,12 +5497,6 @@ "node": ">= 0.4" } }, - "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", @@ -5604,21 +5513,22 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "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": { @@ -5752,9 +5662,9 @@ "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==", + "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": { @@ -5773,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": { @@ -5780,9 +5691,9 @@ } }, "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==", + "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" }, @@ -5844,9 +5755,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "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", @@ -5857,32 +5768,32 @@ "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": { @@ -5926,6 +5837,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", @@ -6033,9 +5945,9 @@ } }, "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==", + "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": { @@ -6044,9 +5956,9 @@ } }, "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==", + "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": { @@ -6113,9 +6025,9 @@ } }, "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==", + "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": { @@ -6137,9 +6049,9 @@ } }, "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==", + "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": { @@ -6190,9 +6102,9 @@ } }, "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==", + "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": { @@ -6201,9 +6113,9 @@ } }, "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==", + "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": { @@ -6472,18 +6384,18 @@ } }, "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==", + "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", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "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" @@ -6509,6 +6421,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 +6438,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" } @@ -6562,9 +6476,9 @@ } }, "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==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -6704,9 +6618,9 @@ } }, "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==", + "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" @@ -6785,7 +6699,7 @@ "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", + "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": { @@ -6817,9 +6731,9 @@ } }, "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==", + "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": { @@ -6828,9 +6742,9 @@ } }, "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==", + "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": { @@ -6934,16 +6848,17 @@ "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==", + "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" }, @@ -7052,6 +6967,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", @@ -7933,12 +7849,12 @@ } }, "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==", + "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" @@ -8058,12 +7974,6 @@ "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", @@ -8294,9 +8204,9 @@ } }, "node_modules/ky": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.2.tgz", - "integrity": "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==", + "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" @@ -8386,9 +8296,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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": { @@ -8480,14 +8390,14 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "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" } }, @@ -8647,10 +8557,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "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" } @@ -8660,6 +8570,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,9 +8614,9 @@ "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==", + "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", @@ -8723,6 +8634,35 @@ "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", @@ -8738,9 +8678,9 @@ } }, "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==", + "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": { @@ -9195,25 +9135,25 @@ "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==", + "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", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "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" @@ -9252,9 +9192,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "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" @@ -9279,9 +9219,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -9318,9 +9258,9 @@ } }, "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==", + "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": { @@ -9379,6 +9319,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 +9411,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", @@ -9509,6 +9451,12 @@ ], "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", @@ -9625,6 +9573,13 @@ "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", @@ -9699,6 +9654,12 @@ ], "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", @@ -9865,6 +9826,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 +9908,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", @@ -10018,6 +9981,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 +9999,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" }, @@ -10068,9 +10033,9 @@ } }, "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==", + "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": "*" @@ -10140,6 +10105,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 +10141,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 +10162,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 +10344,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 +10354,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" } @@ -10506,9 +10476,9 @@ "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==", + "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" @@ -10545,19 +10515,25 @@ "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==", + "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" } @@ -10633,11 +10609,12 @@ }, "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==", + "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" }, @@ -10831,9 +10808,9 @@ "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==", + "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" @@ -11020,15 +10997,18 @@ } }, "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" + "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.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "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": { @@ -11133,9 +11113,9 @@ } }, "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==", + "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": { @@ -11152,9 +11132,9 @@ "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==", + "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" }, @@ -11203,27 +11183,6 @@ "node": ">=8" } }, - "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", - "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", - "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", @@ -11365,19 +11324,6 @@ "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" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -11498,9 +11444,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "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", @@ -11548,9 +11494,9 @@ "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==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -11593,11 +11539,12 @@ } }, "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==", + "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" }, @@ -11606,9 +11553,9 @@ } }, "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==", + "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": { @@ -11819,6 +11766,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12063,6 +12011,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12182,11 +12131,12 @@ } }, "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==", + "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" }, @@ -12195,31 +12145,32 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "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": { @@ -12235,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": { @@ -12269,13 +12221,16 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "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==", + "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": { @@ -12544,12 +12499,12 @@ } }, "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==", + "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" @@ -12636,13 +12591,12 @@ } }, "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==", + "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": { @@ -12716,6 +12670,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", @@ -12727,6 +12682,27 @@ "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", @@ -12737,6 +12713,7 @@ "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", @@ -12772,15 +12749,15 @@ } }, "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==", + "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" @@ -12799,10 +12776,11 @@ } }, "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==", + "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" }, @@ -12849,10 +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", @@ -12873,15 +12873,15 @@ } }, "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==", + "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" @@ -13031,24 +13031,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "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", - "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", @@ -13097,12 +13079,12 @@ } }, "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==", + "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" @@ -13119,9 +13101,9 @@ "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==", + "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": { 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/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index bd6a41523..cec66eb8b 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, @@ -18,6 +18,7 @@ import { useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' +import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { @@ -29,8 +30,8 @@ import { RatingField, SizeField, useAlbumsPerPage, + useImageLoadingState, } from '../common' -import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -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 imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) 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 (
diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 58732bbde..9717618fa 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -13,7 +13,14 @@ 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, + useImageUrl, +} from '../common' +import config from '../config' import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -28,13 +35,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%)', }, @@ -89,6 +94,11 @@ const useStyles = makeStyles( ) const useCoverStyles = makeStyles({ + coverContainer: { + width: '100%', + aspectRatio: '1', + overflow: 'hidden', + }, cover: { display: 'inline-block', width: '100%', @@ -97,7 +107,7 @@ const useCoverStyles = makeStyles({ transition: 'opacity 0.3s ease-in-out', }, coverLoading: { - opacity: 0.5, + opacity: 0, }, }) @@ -117,8 +127,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, @@ -128,32 +136,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, config.uiCoverArtSize, true) + const { imgUrl, loading: imageLoading } = useImageUrl(url) return ( -
+
{record.name}
@@ -198,7 +190,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/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index f10f8dbd3..28a24981c 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 = ({ albumListType, ...rest }) => { + const { loading } = useListContext() + if (loading && albumListType === 'random') { + return null + } + return <Pagination {...rest} /> +} + const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { @@ -234,7 +243,12 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<Pagination rowsPerPageOptions={perPageOptions} />} + pagination={ + <AlbumListPagination + rowsPerPageOptions={perPageOptions} + albumListType={albumListType} + /> + } title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( 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) => ( + <span style={{ marginRight: '8px' }}> + <CoverArtAvatar record={r} variant="square" /> + </span> + )} linkType={'show'} rightIcon={(r) => <AlbumContextMenu record={r} />} {...rest} /> ) : ( <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> + <CoverArtAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <AlbumContextMenu 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 }) => { /> ) : ( <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> + <CoverArtAvatar source="id" /> <TextField source="name" /> <FunctionField source="albumCount" 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/ArtistSimpleList.jsx b/ui/src/artist/ArtistSimpleList.jsx index deeb3edbc..55b6b1a0b 100644 --- a/ui/src/artist/ArtistSimpleList.jsx +++ b/ui/src/artist/ArtistSimpleList.jsx @@ -2,12 +2,13 @@ import React from 'react' import PropTypes from 'prop-types' import List from '@material-ui/core/List' import ListItem from '@material-ui/core/ListItem' +import ListItemAvatar from '@material-ui/core/ListItemAvatar' import ListItemIcon from '@material-ui/core/ListItemIcon' 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, RatingField } from '../common' +import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common' import config from '../config' const useStyles = makeStyles( @@ -47,7 +48,11 @@ const ArtistSimpleList = ({ data[id] && ( <span key={id} onClick={() => linkType(id)}> <ListItem className={classes.listItem} button={true}> + <ListItemAvatar> + <CoverArtAvatar record={data[id]} /> + </ListItemAvatar> <ListItemText + style={{ marginLeft: '8px' }} primary={ <> <div className={classes.title}>{data[id].name}</div> diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 1e074ce4e..dda761097 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,7 +6,12 @@ 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, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' @@ -57,6 +62,7 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', boxShadow: 'none', + position: 'relative', }, artistDetail: { flex: '1', @@ -85,36 +91,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, + } = useImageLoadingState(record.id) return ( <div className={classes.root}> @@ -124,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, 300)} + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} @@ -135,6 +120,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { }} /> )} + <ImageUploadOverlay + entityType="artist" + entityId={record.id} + hasUploadedImage={!!record.uploadedImage} + /> </Card> <div className={classes.details}> <CardContent className={classes.content}> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index e8c044d66..e82d4c28a 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,7 +4,12 @@ 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, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -67,6 +72,7 @@ const useStyles = makeStyles( minWidth: '7rem', display: 'flex', borderRadius: '5em', + position: 'relative', }, loveButton: { top: theme.spacing(-0.2), @@ -83,40 +89,19 @@ 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 - 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, + } = useImageLoadingState(record.id) return ( <> @@ -127,7 +112,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { <CardMedia key={record.id} component="img" - src={subsonic.getCoverArtUrl(record, 300)} + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} onLoad={handleImageLoad} @@ -138,6 +123,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { }} /> )} + <ImageUploadOverlay + entityType="artist" + entityId={record.id} + hasUploadedImage={!!record.uploadedImage} + /> </Card> <div className={classes.details}> <Typography diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx new file mode 100644 index 000000000..f70403774 --- /dev/null +++ b/ui/src/common/CoverArtAvatar.jsx @@ -0,0 +1,52 @@ +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/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx new file mode 100644 index 000000000..a370e40fe --- /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.enableArtworkUpload || 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 ( + <div className={classes.coverOverlay}> + <Tooltip title={translate(`message.uploadCover`)}> + <IconButton + className={classes.overlayButton} + onClick={handleUploadClick} + size="small" + > + <PhotoCameraIcon className={classes.overlayIcon} /> + </IconButton> + </Tooltip> + {hasUploadedImage && ( + <Tooltip title={translate(`message.removeCover`)}> + <IconButton + className={classes.overlayButton} + onClick={handleRemoveCover} + size="small" + > + <DeleteIcon className={classes.overlayIcon} /> + </IconButton> + </Tooltip> + )} + <input + ref={fileInputRef} + type="file" + accept="image/*" + style={{ display: 'none' }} + onChange={handleFileChange} + /> + </div> + ) +} 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 ( + <Tooltip + title={title} + disableHoverListener={!isOverflowing} + disableTouchListener + placement={placement} + TransitionProps={transitionProps} + classes={tooltipClasses} + > + {React.cloneElement(children, { ref: mergedRef })} + </Tooltip> + ) +} + +OverflowTooltip.propTypes = { + children: PropTypes.element.isRequired, + title: PropTypes.string.isRequired, + placement: PropTypes.string, +} diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index 3586cf225..d2c98bbe7 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,55 @@ 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 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) } - 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(() => { + lightboxClosedAt.current = Date.now() + setLightboxOpen(false) + }, []) + + const subtitle = record.discSubtitle + ? record.discSubtitle + : translate('resources.song.fields.disc', { + discNumber: record.discNumber, + }) return ( <TableRow @@ -84,9 +139,28 @@ const DiscSubtitleRow = forwardRef( > <TableCell colSpan={colSpan}> <Typography variant="h6" className={classes.subtitle}> - <AlbumIcon className={classes.discIcon} fontSize={'small'} /> - {subtitle.join(': ')} + {!imageError ? ( + <img + src={coverArtUrl} + className={classes.discCoverArt} + alt="" + onClick={handleOpenLightbox} + onError={() => setImageError(true)} + /> + ) : ( + <AlbumIcon className={classes.discIcon} fontSize={'small'} /> + )} + {subtitle} </Typography> + {isLightboxOpen && !imageError && ( + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.album + ' - ' + subtitle} + mainSrc={fullImageUrl} + onCloseRequest={handleCloseLightbox} + /> + )} </TableCell> <TableCell> <AlbumContextMenu diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 356225680..362a0ced3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,4 +41,9 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './OverflowTooltip' export * from './useSearchRefocus' +export * from './ImageUploadOverlay' +export * from './CoverArtAvatar' +export * from './useImageLoadingState' +export * from './useImageUrl' diff --git a/ui/src/common/useImageLoadingState.js b/ui/src/common/useImageLoadingState.js new file mode 100644 index 000000000..3528b0f3f --- /dev/null +++ b/ui/src/common/useImageLoadingState.js @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback } from 'react' + +/** + * Manages image loading/error state and lightbox open/close. + * Resets when recordId changes. + */ +export const useImageLoadingState = (recordId) => { + const [imageLoading, setImageLoading] = useState(true) + 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, + } +} 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/config.js b/ui/src/config.js index 0672a58f4..f4d60dfba 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -21,8 +21,9 @@ const defaultConfig = { defaultLanguage: '', defaultUIVolume: 100, uiSearchDebounceMs: 200, + uiCoverArtSize: 600, enableUserEditing: true, - enableCoverArtUpload: true, + enableArtworkUpload: true, enableSharing: true, shareURL: '', defaultDownloadableShare: true, diff --git a/ui/src/consts.js b/ui/src/consts.js index e3446c2fe..472cd4940 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', @@ -22,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/i18n/en.json b/ui/src/i18n/en.json index 7c4b8ff09..6c6592178 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -23,6 +23,7 @@ "bitDepth": "Bit depth", "sampleRate": "Sample rate", "channels": "Channels", + "disc": "Disc %{discNumber}", "discSubtitle": "Disc Subtitle", "starred": "Favourite", "comment": "Comment", @@ -218,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", @@ -562,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/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 }) => { <MenuItemLink to={`/playlist/${pls.id}/show`} primaryText={ - <Typography variant="inherit" noWrap ref={dropRef}> - {pls.name} - </Typography> + <OverflowTooltip title={pls.name} placement="right"> + <Typography variant="inherit" noWrap ref={dropRef}> + {pls.name} + </Typography> + </OverflowTooltip> } sidebarIsOpen={sidebarIsOpen} dense={false} diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index eefed83b4..c396cbbeb 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,28 +2,24 @@ 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 Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { CollapsibleComment, DurationField, + ImageUploadOverlay, SizeField, isWritable, + OverflowTooltip, + useImageLoadingState, } from '../common' import config from '../config' import subsonic from '../subsonic' -import { REST_URL } from '../consts' -import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -81,31 +77,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', @@ -124,98 +95,20 @@ 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 { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) - const imageUrl = subsonic.getCoverArtUrl(record, 300, true) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - const canEdit = - isWritable(record.ownerId) && - (config.enableCoverArtUpload || localStorage.getItem('role') === 'admin') - - // 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), []) - - 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 ( <Card className={classes.root}> @@ -236,50 +129,24 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> - {canEdit && ( - <div className={classes.coverOverlay}> - <Tooltip - title={translate('resources.playlist.actions.uploadCover')} - > - <IconButton - className={classes.overlayButton} - onClick={handleUploadClick} - size="small" - > - <PhotoCameraIcon className={classes.overlayIcon} /> - </IconButton> - </Tooltip> - {record.uploadedImage && ( - <Tooltip - title={translate('resources.playlist.actions.removeCover')} - > - <IconButton - className={classes.overlayButton} - onClick={handleRemoveCover} - size="small" - > - <DeleteIcon className={classes.overlayIcon} /> - </IconButton> - </Tooltip> - )} - <input - ref={fileInputRef} - type="file" - accept="image/*" - style={{ display: 'none' }} - onChange={handleFileChange} - /> - </div> + {isWritable(record.ownerId) && ( + <ImageUploadOverlay + entityType="playlist" + entityId={record.id} + hasUploadedImage={!!record.uploadedImage} + /> )} </div> <div className={classes.details}> <CardContent className={classes.content}> - <Typography - variant={isDesktop ? 'h5' : 'h6'} - className={classes.title} - > - {record.name || translate('ra.page.loading')} - </Typography> + <OverflowTooltip title={record.name || ''}> + <Typography + variant={isDesktop ? 'h5' : 'h6'} + className={classes.title} + > + {record.name || translate('ra.page.loading')} + </Typography> + </OverflowTooltip> <Typography component="p" className={classes.stats}> {record.songCount ? ( <span> 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 ( - <Avatar - src={subsonic.getCoverArtUrl(record, 80, true)} - variant="square" - className={classes.coverArt} - alt={record.name} - /> - ) -} - -CoverArtField.defaultProps = { - label: '', - sortable: false, -} - const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -204,7 +179,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> - <CoverArtField source="id" /> + <CoverArtAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <Writable> diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index f00f889f3..bbe001e6f 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -6,8 +6,38 @@ 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 config from '../config' +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 +51,7 @@ const RadioEdit = (props) => { return ( <Edit title={<RadioTitle />} {...props}> <SimpleForm variant="outlined" {...props}> + <RadioCoverArt /> <TextInput source="name" validate={[required()]} /> <TextInput type="url" @@ -41,4 +72,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, config.uiCoverArtSize, 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..945bac519 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, @@ -14,11 +14,17 @@ 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' import { songFromRadio } from './helper' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { useDispatch } from 'react-redux' const useStyles = makeStyles({ @@ -73,6 +79,21 @@ const RadioListActions = ({ ) } +const avatarStyle = { width: 40, height: 40 } + +const CoverArtField = ({ record }) => { + const directUrl = record?.uploadedImage + ? subsonic.getCoverArtUrl(record, 40, true) + : 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} /> + ) +} +CoverArtField.defaultProps = { label: '' } + const RadioList = ({ permissions, ...props }) => { const classes = useStyles() const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -80,6 +101,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 +119,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 +139,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..b451dae9a 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -1,16 +1,25 @@ +import subsonic from '../subsonic' +import config from '../config' +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, config.uiCoverArtSize, 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/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, } } 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) + }, + ) +}) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index cfcc01043..3579619aa 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -86,11 +86,24 @@ 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)) } } +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 +142,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..a750694f4 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -1,4 +1,5 @@ import { vi } from 'vitest' +import config from '../config' import subsonic from './index' describe('getCoverArtUrl', () => { @@ -30,10 +31,14 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + 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 +49,14 @@ describe('getCoverArtUrl', () => { sync: true, } - const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + 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 +68,14 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(albumRecord, 300, true) + const url = subsonic.getCoverArtUrl( + albumRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('al-album-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) @@ -73,10 +86,10 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(songRecord, 300, true) + const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true) expect(url).toContain('mf-song-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) @@ -86,10 +99,14 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(artistRecord, 300, true) + const url = subsonic.getCoverArtUrl( + artistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('ar-artist-123') - expect(url).toContain('size=300') + expect(url).toContain('size=600') expect(url).toContain('square=true') }) @@ -105,6 +122,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 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, + }, +} 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']) + }) + }) }) 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() + } + }) + } +} 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)) + }) +}) 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()) + }) +}) 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), + ) +}) 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)) + }) +})