diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f339f62f7..ff58994db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,10 +4,10 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.24", + "VARIANT": "1.25", // Options "INSTALL_NODE": "true", - "NODE_VERSION": "v20" + "NODE_VERSION": "v24" } }, "workspaceMount": "", diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9488f20f7..0767346fa 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -25,7 +25,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -63,7 +63,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -93,7 +93,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -106,7 +106,7 @@ jobs: - name: Test run: | pkg-config --define-prefix --cflags --libs taglib # for debugging - go test -shuffle=on -tags netgo -race -cover ./... -v + go test -shuffle=on -tags netgo -race ./... -v js: name: Test JS code @@ -114,10 +114,10 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -145,7 +145,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: | set -e for file in resources/i18n/*.json; do @@ -191,7 +191,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -217,7 +217,7 @@ jobs: CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} - name: Upload Binaries - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: navidrome-${{ env.PLATFORM }} path: ./output @@ -248,7 +248,7 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' with: name: digests-${{ env.PLATFORM }} @@ -264,10 +264,10 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: path: /tmp/digests pattern: digests-* @@ -318,9 +318,9 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: ./binaries pattern: navidrome-windows* @@ -339,7 +339,7 @@ jobs: du -h binaries/msi/*.msi - name: Upload MSI files - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: navidrome-windows-installers path: binaries/msi/*.msi @@ -352,12 +352,12 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: ./binaries pattern: navidrome-* @@ -383,7 +383,7 @@ jobs: rm ./dist/*.tar.gz ./dist/*.zip - name: Upload all-packages artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: packages path: dist/navidrome_0* @@ -406,13 +406,13 @@ jobs: item: ${{ fromJson(needs.release.outputs.package_list) }} steps: - name: Download all-packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: packages path: ./dist - name: Upload all-packages artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: navidrome_linux_${{ matrix.item }} path: dist/navidrome_0*_linux_${{ matrix.item }} diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 70a9de3d8..69ca1cc94 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get updated translations id: poeditor env: diff --git a/.nvmrc b/.nvmrc index 9a2a0e219..54c65116f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v24 diff --git a/Dockerfile b/Dockerfile index ec3b6d938..fb1cf997b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ ARG TARGETPLATFORM ARG CROSS_TAGLIB_VERSION=2.1.1-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ +# wget in busybox can't follow redirects RUN < /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6) + @INSTALL=false; \ + if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \ + if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \ + echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \ + rm -f ./bin/golangci-lint; \ + INSTALL=true; \ + fi; \ + else \ + INSTALL=true; \ + fi; \ + if [ "$$INSTALL" = "true" ]; then \ + echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \ + fi .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go index 62a949d85..d32adf4ed 100644 --- a/adapters/taglib/taglib.go +++ b/adapters/taglib/taglib.go @@ -43,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { // Parse audio properties ap := metadata.AudioProperties{} - if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 { - millis, _ := strconv.Atoi(length[0]) - if millis > 0 { - ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10) - } - delete(tags, "_lengthinmilliseconds") - } - parseProp := func(prop string, target *int) { - if value, ok := tags[prop]; ok && len(value) > 0 { - *target, _ = strconv.Atoi(value[0]) - delete(tags, prop) - } - } - parseProp("_bitrate", &ap.BitRate) - parseProp("_channels", &ap.Channels) - parseProp("_samplerate", &ap.SampleRate) - parseProp("_bitspersample", &ap.BitDepth) + ap.BitRate = parseProp(tags, "__bitrate") + ap.Channels = parseProp(tags, "__channels") + ap.SampleRate = parseProp(tags, "__samplerate") + ap.BitDepth = parseProp(tags, "__bitspersample") + length := parseProp(tags, "__lengthinmilliseconds") + ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10) + + // Extract basic tags + parseBasicTag(tags, "__title", "title") + parseBasicTag(tags, "__artist", "artist") + parseBasicTag(tags, "__album", "album") + parseBasicTag(tags, "__comment", "comment") + parseBasicTag(tags, "__genre", "genre") + parseBasicTag(tags, "__year", "year") + parseBasicTag(tags, "__track", "tracknumber") // Parse track/disc totals parseTuple := func(prop string) { @@ -107,6 +105,31 @@ var tiplMapping = map[string]string{ "DJ-mix": "djmixer", } +// parseProp parses a property from the tags map and sets it to the target integer. +// It also deletes the property from the tags map after parsing. +func parseProp(tags map[string][]string, prop string) int { + if value, ok := tags[prop]; ok && len(value) > 0 { + v, _ := strconv.Atoi(value[0]) + delete(tags, prop) + return v + } + return 0 +} + +// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map. +// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.), +// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag. +func parseBasicTag(tags map[string][]string, basicName string, tagName string) { + basicValue := tags[basicName] + if len(basicValue) == 0 { + return + } + delete(tags, basicName) + if len(tags[tagName]) == 0 { + tags[tagName] = basicValue + } +} + // parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: // // "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp index 224642c6d..2985e8f18 100644 --- a/adapters/taglib/taglib_wrapper.cpp +++ b/adapters/taglib/taglib_wrapper.cpp @@ -45,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { // Add audio properties to the tags const TagLib::AudioProperties *props(f.audioProperties()); - goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds()); - goPutInt(id, (char *)"_bitrate", props->bitrate()); - goPutInt(id, (char *)"_channels", props->channels()); - goPutInt(id, (char *)"_samplerate", props->sampleRate()); + goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds()); + goPutInt(id, (char *)"__bitrate", props->bitrate()); + goPutInt(id, (char *)"__channels", props->channels()); + goPutInt(id, (char *)"__samplerate", props->sampleRate()); + // Extract bits per sample for supported formats + int bitsPerSample = 0; if (const auto* apeProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample()); - if (const auto* asfProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample()); + bitsPerSample = apeProperties->bitsPerSample(); + else if (const auto* asfProperties{ dynamic_cast(props) }) + bitsPerSample = asfProperties->bitsPerSample(); else if (const auto* flacProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample()); + bitsPerSample = flacProperties->bitsPerSample(); else if (const auto* mp4Properties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample()); + bitsPerSample = mp4Properties->bitsPerSample(); else if (const auto* wavePackProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample()); + bitsPerSample = wavePackProperties->bitsPerSample(); else if (const auto* aiffProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample()); + bitsPerSample = aiffProperties->bitsPerSample(); else if (const auto* wavProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample()); + bitsPerSample = wavProperties->bitsPerSample(); else if (const auto* dsfProperties{ dynamic_cast(props) }) - goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample()); + bitsPerSample = dsfProperties->bitsPerSample(); + + if (bitsPerSample > 0) { + goPutInt(id, (char *)"__bitspersample", bitsPerSample); + } // Send all properties to the Go map TagLib::PropertyMap tags = f.file()->properties(); + // Make sure at least the basic properties are extracted + TagLib::Tag *basic = f.file()->tag(); + if (!basic->isEmpty()) { + if (!basic->title().isEmpty()) { + tags.insert("__title", basic->title()); + } + if (!basic->artist().isEmpty()) { + tags.insert("__artist", basic->artist()); + } + if (!basic->album().isEmpty()) { + tags.insert("__album", basic->album()); + } + if (!basic->comment().isEmpty()) { + tags.insert("__comment", basic->comment()); + } + if (!basic->genre().isEmpty()) { + tags.insert("__genre", basic->genre()); + } + if (basic->year() > 0) { + tags.insert("__year", TagLib::String::number(basic->year())); + } + if (basic->track() > 0) { + tags.insert("__track", TagLib::String::number(basic->track())); + } + } + TagLib::ID3v2::Tag *id3Tags = NULL; // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 187ab488d..bf13dc731 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, scannerScanner) library := core.NewLibrary(dataStore, scannerScanner, watcher, broker) - router := nativeapi.New(dataStore, share, playlists, insights, library) + maintenance := core.NewMaintenance(dataStore) + router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance) return router } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 4125d6de0..7ae3a16e0 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() { }) It("deduplicates items in buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw.PreCache(model.MustParseArtworkID("al-1")) cw.PreCache(model.MustParseArtworkID("al-1")) diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 6d4a4cc6f..857dc2eef 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/ioutils" ) func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { @@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( externalLyric := basePath[0:len(basePath)-len(ext)] + suffix - contents, err := os.ReadFile(externalLyric) - + contents, err := ioutils.UTF8ReadFile(externalLyric) if errors.Is(err, os.ErrNotExist) { log.Trace(ctx, "no lyrics found at path", "path", externalLyric) return nil, nil diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index e92564c00..b3d502101 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -108,5 +108,39 @@ var _ = Describe("sources", func() { }, })) }) + + It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { + // The function looks for , so we need to pass + // a MediaFile with .mp3 path and look for .lrc suffix + mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // The critical assertion: even with BOM, synced should be true + Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") + Expect(lyrics[0].Line).To(HaveLen(1)) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) + }) + + It("should handle UTF-16 LE encoded LRC files", func() { + mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // UTF-16 should be properly converted to UTF-8 + Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") + Expect(lyrics[0].Line).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) + Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) + }) }) }) diff --git a/core/maintenance.go b/core/maintenance.go new file mode 100644 index 000000000..c2f65d74f --- /dev/null +++ b/core/maintenance.go @@ -0,0 +1,226 @@ +package core + +import ( + "context" + "fmt" + "slices" + "sync" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" +) + +type Maintenance interface { + // DeleteMissingFiles deletes specific missing files by their IDs + DeleteMissingFiles(ctx context.Context, ids []string) error + // DeleteAllMissingFiles deletes all files marked as missing + DeleteAllMissingFiles(ctx context.Context) error +} + +type maintenanceService struct { + ds model.DataStore + wg sync.WaitGroup +} + +func NewMaintenance(ds model.DataStore) Maintenance { + return &maintenanceService{ + ds: ds, + } +} + +func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error { + return s.deleteMissing(ctx, ids) +} + +func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { + return s.deleteMissing(ctx, nil) +} + +// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations +func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { + // Track affected album IDs before deletion for refresh + affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids) + if err != nil { + log.Warn(ctx, "Error tracking affected albums for refresh", err) + // Don't fail the operation, just log the warning + } + + // Delete missing files within a transaction + err = s.ds.WithTx(func(tx model.DataStore) error { + if len(ids) == 0 { + _, err := tx.MediaFile(ctx).DeleteAllMissing() + return err + } + return tx.MediaFile(ctx).DeleteMissing(ids) + }) + if err != nil { + log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) + return err + } + + // Run garbage collection to clean up orphaned records + if err := s.ds.GC(ctx); err != nil { + log.Error(ctx, "Error running GC after deleting missing tracks", err) + return err + } + + // Refresh statistics in background + s.refreshStatsAsync(ctx, affectedAlbumIDs) + + return nil +} + +// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files. +// It uses batch queries to minimize database round-trips for efficiency. +func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error { + if len(albumIDs) == 0 { + return nil + } + + log.Debug(ctx, "Refreshing albums", "count", len(albumIDs)) + + // Process in chunks to avoid query size limits + const chunkSize = 100 + for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) { + if err := s.refreshAlbumChunk(ctx, chunk); err != nil { + return fmt.Errorf("refreshing album chunk: %w", err) + } + } + + log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs)) + return nil +} + +// refreshAlbumChunk processes a single chunk of album IDs +func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error { + albumRepo := s.ds.Album(ctx) + mfRepo := s.ds.MediaFile(ctx) + + // Batch load existing albums + albums, err := albumRepo.GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.id": albumIDs}, + }) + if err != nil { + return fmt.Errorf("loading albums: %w", err) + } + + // Create a map for quick lookup + albumMap := make(map[string]*model.Album, len(albums)) + for i := range albums { + albumMap[albums[i].ID] = &albums[i] + } + + // Batch load all media files for these albums + mediaFiles, err := mfRepo.GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album_id": albumIDs}, + Sort: "album_id, path", + }) + if err != nil { + return fmt.Errorf("loading media files: %w", err) + } + + // Group media files by album ID + filesByAlbum := make(map[string]model.MediaFiles) + for i := range mediaFiles { + albumID := mediaFiles[i].AlbumID + filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i]) + } + + // Recalculate each album from its media files + for albumID, oldAlbum := range albumMap { + mfs, hasTracks := filesByAlbum[albumID] + if !hasTracks { + // Album has no tracks anymore, skip (will be cleaned up by GC) + log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID) + continue + } + + // Recalculate album from media files + newAlbum := mfs.ToAlbum() + + // Only update if something changed (avoid unnecessary writes) + if !oldAlbum.Equals(newAlbum) { + // Preserve original timestamps + newAlbum.UpdatedAt = time.Now() + newAlbum.CreatedAt = oldAlbum.CreatedAt + + if err := albumRepo.Put(&newAlbum); err != nil { + log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err) + // Continue with other albums instead of failing entirely + continue + } + log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name) + } + } + + return nil +} + +// getAffectedAlbumIDs returns distinct album IDs from missing media files +func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) { + var filters squirrel.Sqlizer = squirrel.Eq{"missing": true} + if len(ids) > 0 { + filters = squirrel.And{ + squirrel.Eq{"missing": true}, + squirrel.Eq{"id": ids}, + } + } + + mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: filters, + }) + if err != nil { + return nil, err + } + + // Extract unique album IDs + albumIDMap := make(map[string]struct{}, len(mfs)) + for _, mf := range mfs { + if mf.AlbumID != "" { + albumIDMap[mf.AlbumID] = struct{}{} + } + } + + albumIDs := make([]string, 0, len(albumIDMap)) + for id := range albumIDMap { + albumIDs = append(albumIDs, id) + } + + return albumIDs, nil +} + +// refreshStatsAsync refreshes artist and album statistics in background goroutines +func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) { + // Refresh artist stats in background + s.wg.Add(1) + go func() { + defer s.wg.Done() + bgCtx := request.AddValues(context.Background(), ctx) + if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { + log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + } else { + log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") + } + + // Refresh album stats in background if we have affected albums + if len(affectedAlbumIDs) > 0 { + if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil { + log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err) + } else { + log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) + } + } + }() +} + +// Wait waits for all background goroutines to complete. +// WARNING: This method is ONLY for testing. Never call this in production code. +// Calling Wait() in production will block until ALL background operations complete +// and may cause race conditions with new operations starting. +func (s *maintenanceService) wait() { + s.wg.Wait() +} diff --git a/core/maintenance_test.go b/core/maintenance_test.go new file mode 100644 index 000000000..8e8796ffa --- /dev/null +++ b/core/maintenance_test.go @@ -0,0 +1,382 @@ +package core + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" +) + +var _ = Describe("Maintenance", func() { + var ds *extendedDataStore + var mfRepo *extendedMediaFileRepo + var service Maintenance + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true}) + + ds = createTestDataStore() + mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo) + service = NewMaintenance(ds) + }) + + Describe("DeleteMissingFiles", func() { + Context("with specific IDs", func() { + It("deletes specific missing files and runs GC", func() { + // Setup: mock missing files with album IDs + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album2", Missing: true}, + }) + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.deleteMissingCalled).To(BeTrue()) + Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"})) + Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion") + }) + + It("triggers artist stats refresh and album refresh after deletion", func() { + artistRepo := ds.MockedArtist.(*extendedArtistRepo) + // Setup: mock missing files with albums + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Test Album", SongCount: 5}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180}, + {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200}, + }) + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).ToNot(HaveOccurred()) + + // Wait for background goroutines to complete + service.(*maintenanceService).wait() + + // RefreshStats should be called + Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") + + // Album should be updated with new calculated values + Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data") + }) + + It("returns error if deletion fails", func() { + mfRepo.deleteMissingError = errors.New("delete failed") + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("delete failed")) + }) + + It("continues even if album tracking fails", func() { + mfRepo.SetError(true) + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + // Should not fail, just log warning + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.deleteMissingCalled).To(BeTrue()) + }) + + It("returns error if GC fails", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + }) + + // Set GC to return error + ds.gcError = errors.New("gc failed") + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("gc failed")) + }) + }) + + Context("album ID extraction", func() { + It("extracts unique album IDs from missing files", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: true}, + {ID: "mf3", AlbumID: "album2", Missing: true}, + }) + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"}) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips files without album IDs", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: true}, + }) + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) + + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("DeleteAllMissingFiles", func() { + It("deletes all missing files and runs GC", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album2", Missing: true}, + {ID: "mf3", AlbumID: "album3", Missing: true}, + }) + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion") + }) + + It("returns error if deletion fails", func() { + mfRepo.SetError(true) + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).To(HaveOccurred()) + }) + + It("handles empty result gracefully", func() { + mfRepo.SetData(model.MediaFiles{}) + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Album refresh logic", func() { + var albumRepo *extendedAlbumRepo + + BeforeEach(func() { + albumRepo = ds.MockedAlbum.(*extendedAlbumRepo) + }) + + Context("when album has no tracks after deletion", func() { + It("skips the album without updating it", func() { + // Setup album with no remaining tracks + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Empty Album", SongCount: 1}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + }) + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).ToNot(HaveOccurred()) + + // Wait for background goroutines to complete + service.(*maintenanceService).wait() + + // Album should NOT be updated because it has no tracks left + Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated") + }) + }) + + Context("when Put fails for one album", func() { + It("continues processing other albums", func() { + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Album 1"}, + {ID: "album2", Name: "Album 2"}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180}, + {ID: "mf3", AlbumID: "album2", Missing: true}, + {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, + }) + + // Make Put fail on first call but succeed on subsequent calls + albumRepo.putError = errors.New("put failed") + albumRepo.failOnce = true + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"}) + + // Should not fail even if one album's Put fails + Expect(err).ToNot(HaveOccurred()) + + // Wait for background goroutines to complete + service.(*maintenanceService).wait() + + // Put should have been called multiple times + Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted") + }) + }) + + Context("when media file loading fails", func() { + It("logs warning but continues when tracking affected albums fails", func() { + // Set up log capturing + hook, cleanup := tests.LogHook() + defer cleanup() + + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Album 1"}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + }) + // Make GetAll fail when loading media files + mfRepo.SetError(true) + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + // Deletion should succeed despite the tracking error + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.deleteMissingCalled).To(BeTrue()) + + // Verify the warning was logged + Expect(hook.LastEntry()).ToNot(BeNil()) + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh")) + }) + }) + }) +}) + +// Test helper to create a mock DataStore with controllable behavior +func createTestDataStore() *extendedDataStore { + // Create extended datastore with GC tracking + ds := &extendedDataStore{ + MockDataStore: &tests.MockDataStore{}, + } + + // Create extended album repo with Put tracking + albumRepo := &extendedAlbumRepo{ + MockAlbumRepo: tests.CreateMockAlbumRepo(), + } + ds.MockedAlbum = albumRepo + + // Create extended artist repo with RefreshStats tracking + artistRepo := &extendedArtistRepo{ + MockArtistRepo: tests.CreateMockArtistRepo(), + } + ds.MockedArtist = artistRepo + + // Create extended media file repo with DeleteMissing support + mfRepo := &extendedMediaFileRepo{ + MockMediaFileRepo: tests.CreateMockMediaFileRepo(), + } + ds.MockedMediaFile = mfRepo + + return ds +} + +// Extension of MockMediaFileRepo to add DeleteMissing method +type extendedMediaFileRepo struct { + *tests.MockMediaFileRepo + deleteMissingCalled bool + deletedIDs []string + deleteMissingError error +} + +func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error { + m.deleteMissingCalled = true + m.deletedIDs = ids + if m.deleteMissingError != nil { + return m.deleteMissingError + } + // Actually delete from the mock data + for _, id := range ids { + delete(m.Data, id) + } + return nil +} + +// Extension of MockAlbumRepo to track Put calls +type extendedAlbumRepo struct { + *tests.MockAlbumRepo + mu sync.RWMutex + putCallCount int + lastPutData *model.Album + putError error + failOnce bool +} + +func (m *extendedAlbumRepo) Put(album *model.Album) error { + m.mu.Lock() + m.putCallCount++ + m.lastPutData = album + + // Handle failOnce behavior + var err error + if m.putError != nil { + if m.failOnce { + err = m.putError + m.putError = nil // Clear error after first failure + m.mu.Unlock() + return err + } + err = m.putError + m.mu.Unlock() + return err + } + m.mu.Unlock() + + return m.MockAlbumRepo.Put(album) +} + +func (m *extendedAlbumRepo) GetPutCallCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.putCallCount +} + +// Extension of MockArtistRepo to track RefreshStats calls +type extendedArtistRepo struct { + *tests.MockArtistRepo + mu sync.RWMutex + refreshStatsCalled bool + refreshStatsError error +} + +func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) { + m.mu.Lock() + m.refreshStatsCalled = true + err := m.refreshStatsError + m.mu.Unlock() + + if err != nil { + return 0, err + } + return m.MockArtistRepo.RefreshStats(allArtists) +} + +func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.refreshStatsCalled +} + +// Extension of MockDataStore to track GC calls +type extendedDataStore struct { + *tests.MockDataStore + gcCalled bool + gcError error +} + +func (ds *extendedDataStore) GC(ctx context.Context) error { + ds.gcCalled = true + if ds.gcError != nil { + return ds.gcError + } + return ds.MockDataStore.GC(ctx) +} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f4f8738e7..010c24c28 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -6,6 +6,7 @@ import ( "encoding/json" "math" "net/http" + "os" "path/filepath" "runtime" "runtime/debug" @@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data { data.Build.Settings, data.Build.GoVersion = buildInfo() data.OS.Containerized = consts.InContainer + // Install info + packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFileData, err := os.ReadFile(packageFilename) + if err == nil { + data.OS.Package = string(packageFileData) + } + // OS info data.OS.Type = runtime.GOOS data.OS.Arch = runtime.GOARCH diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 105a6218e..c46eb8743 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -16,6 +16,7 @@ type Data struct { Containerized bool `json:"containerized"` Arch string `json:"arch"` NumCPU int `json:"numCPU"` + Package string `json:"package,omitempty"` } `json:"os"` Mem struct { Alloc uint64 `json:"alloc"` diff --git a/core/playlists.go b/core/playlists.go index 2eebc94e7..f98179f88 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) @@ -97,12 +98,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold } defer file.Close() + reader := ioutils.UTF8Reader(file) extension := strings.ToLower(filepath.Ext(playlistFile)) switch extension { case ".nsp": - err = s.parseNSP(ctx, pls, file) + err = s.parseNSP(ctx, pls, reader) default: - err = s.parseM3U(ctx, pls, folder, file) + err = s.parseM3U(ctx, pls, folder, reader) } return pls, err } diff --git a/core/playlists_test.go b/core/playlists_test.go index 399210ac8..fb42f9c9f 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -74,6 +74,24 @@ var _ = Describe("Playlists", func() { Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) + + It("parses playlists with UTF-8 BOM marker", func() { + pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.OwnerID).To(Equal("123")) + Expect(pls.Name).To(Equal("Test Playlist")) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) + }) + + It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { + pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.OwnerID).To(Equal("123")) + Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) + }) }) Describe("NSP", func() { diff --git a/core/share.go b/core/share.go index 202c27d89..eb5e6679b 100644 --- a/core/share.go +++ b/core/share.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Share interface { @@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { log.Error(r.ctx, "Invalid Resource ID", "id", firstId) return "", model.ErrNotFound } - if len(s.Contents) > 30 { - s.Contents = s.Contents[:26] + "..." - } + + s.Contents = str.TruncateRunes(s.Contents, 30, "...") id, err = r.Persistable.Save(s) return id, err diff --git a/core/share_test.go b/core/share_test.go index 21069bb59..475d40ec9 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -38,6 +38,38 @@ var _ = Describe("Share", func() { Expect(id).ToNot(BeEmpty()) Expect(entity.ID).To(Equal(id)) }) + + It("does not truncate ASCII labels shorter than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("Example Media File")) + }) + + It("truncates ASCII labels longer than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) + entity := &model.Share{Description: "test", ResourceIDs: "789"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("Example Media File But The ...")) + }) + + It("does not truncate CJK labels shorter than 30 runes", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("青春コンプレックス")) + }) + + It("truncates CJK labels longer than 30 runes", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) + entity := &model.Share{Description: "test", ResourceIDs: "789"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) + }) }) Describe("Update", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index ae365156a..16335645c 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -18,6 +18,7 @@ var Set = wire.NewSet( NewShare, NewPlaylists, NewLibrary, + NewMaintenance, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql new file mode 100644 index 000000000..de20f0c79 --- /dev/null +++ b/db/migrations/20250823142158_make_playqueue_position_int.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE playqueue ADD COLUMN position_int integer; +UPDATE playqueue SET position_int = CAST(position as INTEGER) ; +ALTER TABLE playqueue DROP COLUMN position; +ALTER TABLE playqueue RENAME COLUMN position_int TO position; +-- +goose StatementEnd + +-- +goose Down diff --git a/go.mod b/go.mod index e1a827f1d..dcc77d063 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/navidrome/navidrome -go 1.24.5 +go 1.25.4 -// Fork to fix https://github.com/navidrome/navidrome/pull/3254 +// Fork to fix https://github.com/navidrome/navidrome/issues/3254 replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d 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.9.0 + github.com/bmatcuk/doublestar/v4 v4.9.1 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 @@ -22,15 +22,15 @@ require ( github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 - github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-viper/encoding/ini v0.1.1 - github.com/gohugoio/hashstructure v0.5.0 + github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 - github.com/google/wire v0.6.0 + github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.0 @@ -40,39 +40,40 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.29 + github.com/mattn/go-sqlite3 v1.14.32 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.38.0 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pocketbase/dbx v1.11.0 - github.com/pressly/goose/v3 v3.24.3 - github.com/prometheus/client_golang v1.22.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 - github.com/tetratelabs/wazero v1.9.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/tetratelabs/wazero v1.10.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 - golang.org/x/image v0.29.0 - golang.org/x/net v0.42.0 - golang.org/x/sync v0.16.0 - golang.org/x/sys v0.34.0 - golang.org/x/text v0.27.0 - golang.org/x/time v0.12.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 + golang.org/x/image v0.32.0 + golang.org/x/net v0.46.0 + golang.org/x/sync v0.18.0 + golang.org/x/sys v0.38.0 + golang.org/x/text v0.30.0 + golang.org/x/time v0.14.0 + google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -86,9 +87,9 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.17.1 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -108,27 +109,28 @@ require ( github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect - github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/pflag v1.0.7 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/tools v0.35.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect + golang.org/x/tools v0.38.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 36558f264..10feea900 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= @@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= -github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= @@ -60,8 +62,14 @@ 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/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -71,8 +79,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= @@ -81,25 +89,24 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= -github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= -github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -115,6 +122,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= @@ -153,14 +162,18 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/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.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= -github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/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= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= @@ -173,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= -github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -188,16 +201,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= -github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -212,12 +223,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= -github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= -github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -229,19 +240,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -252,12 +261,20 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk= +github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= @@ -267,34 +284,34 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= -golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= 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.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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= @@ -303,12 +320,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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= @@ -316,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -331,19 +347,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 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= @@ -357,24 +373,23 @@ 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -386,11 +401,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= -modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/log/log.go b/log/log.go index 20119ab46..ea34e5dcb 100644 --- a/log/log.go +++ b/log/log.go @@ -11,6 +11,7 @@ import ( "runtime" "sort" "strings" + "sync" "time" "github.com/sirupsen/logrus" @@ -70,6 +71,7 @@ type levelPath struct { var ( currentLevel Level + loggerMu sync.RWMutex defaultLogger = logrus.New() logSourceLine = false rootPath string @@ -79,7 +81,9 @@ var ( // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { currentLevel = l + loggerMu.Lock() defaultLogger.Level = logrus.TraceLevel + loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) } @@ -125,6 +129,8 @@ func SetLogSourceLine(enabled bool) { func SetRedacting(enabled bool) { if enabled { + loggerMu.Lock() + defer loggerMu.Unlock() defaultLogger.AddHook(redacted) } } @@ -133,6 +139,8 @@ func SetOutput(w io.Writer) { if runtime.GOOS == "windows" { w = CRLFWriter(w) } + loggerMu.Lock() + defer loggerMu.Unlock() defaultLogger.SetOutput(w) } @@ -158,6 +166,8 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte } func SetDefaultLogger(l *logrus.Logger) { + loggerMu.Lock() + defer loggerMu.Unlock() defaultLogger = l } @@ -204,6 +214,8 @@ func log(level Level, args ...interface{}) { } func Writer() io.Writer { + loggerMu.RLock() + defer loggerMu.RUnlock() return defaultLogger.Writer() } @@ -314,6 +326,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) { func createNewLogger() *logrus.Entry { //logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}) //l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true} + loggerMu.RLock() + defer loggerMu.RUnlock() logger := logrus.NewEntry(defaultLogger) return logger } diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index fa92c5aca..54ac59697 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string { if f.order != "" { mapped = f.order } else if f.isTag { - mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')" + // Use the actual field name (handles aliases like albumtype -> releasetype) + tagName := sortField + if f.field != "" { + tagName = f.field + } + mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" } else if f.isRole { mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" } else { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 3792264a5..032ead5c8 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() { ) }) + It("sorts by albumtype alias (resolves to releasetype)", func() { + AddTagNames([]string{"releasetype"}) + goObj.Sort = "albumtype" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal( + "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", + ), + ) + }) + It("sorts by random", func() { newObj := goObj newObj.Sort = "random" diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 3699eb14a..70719cd6f 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{ "sortalbum": {field: "media_file.sort_album_name"}, "sortartist": {field: "media_file.sort_artist_name"}, "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"}, "albumcomment": {field: "media_file.mbz_album_comment"}, "catalognumber": {field: "media_file.catalog_num"}, "filepath": {field: "media_file.path"}, @@ -55,6 +54,9 @@ var fieldMap = map[string]*mappedField{ "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, "library_id": {field: "media_file.library_id", numeric: true}, + // Backward compatibility: albumtype is an alias for releasetype tag + "albumtype": {field: "releasetype", isTag: true}, + // special fields "random": {field: "", order: "random()"}, // pseudo-field for random sorting "value": {field: "value"}, // pseudo-field for tag and roles values @@ -154,13 +156,19 @@ type tagCond struct { func (e tagCond) ToSql() (string, []any, error) { cond, args, err := e.cond.ToSql() - // Check if this tag is marked as numeric in the fieldMap - if fm, ok := fieldMap[e.tag]; ok && fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + // Resolve the actual tag name (handles aliases like albumtype -> releasetype) + tagName := e.tag + if fm, ok := fieldMap[e.tag]; ok { + if fm.field != "" { + tagName = fm.field + } + if fm.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } } cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", - e.tag, cond) + tagName, cond) if e.not { cond = "not " + cond } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index ee716a9cd..4c1db1303 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -105,6 +105,40 @@ var _ = Describe("Operators", func() { gomega.Expect(sql).To(gomega.BeEmpty()) gomega.Expect(args).To(gomega.BeEmpty()) }) + It("supports releasetype as multi-valued tag", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"releasetype": "soundtrack"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) + }) + It("supports albumtype as alias for releasetype", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"albumtype": "live"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%live%")) + }) + It("supports albumtype alias with Is operator", func() { + AddTagNames([]string{"releasetype"}) + op := Is{"albumtype": "album"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("album")) + }) + It("supports albumtype alias with IsNot operator", func() { + AddTagNames([]string{"releasetype"}) + op := IsNot{"albumtype": "compilation"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("compilation")) + }) }) Describe("Custom Roles", func() { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 4be89bcb8..a062b4398 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() { It("returns all records sorted", func() { Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ albumAbbeyRoad, + albumMultiDisc, albumRadioactivity, albumSgtPeppers, })) @@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() { Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ albumSgtPeppers, albumRadioactivity, + albumMultiDisc, albumAbbeyRoad, })) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index a7cf9272a..6d08c27db 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -400,23 +400,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { // This now calculates per-library statistics and stores them in library_artist.stats batchUpdateStatsSQL := ` WITH artist_role_counters AS ( - SELECT jt.atom AS artist_id, + SELECT mfa.artist_id, mf.library_id, - substr( - replace(jt.path, '$.', ''), - 1, - CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0 - THEN instr(replace(jt.path, '$.', ''), '[') - 1 - ELSE length(replace(jt.path, '$.', '')) - END - ) AS role, + mfa.role, count(DISTINCT mf.album_id) AS album_count, - count(mf.id) AS count, + count(DISTINCT mf.id) AS count, sum(mf.size) AS size - FROM media_file mf - JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL - WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY jt.atom, mf.library_id, role + FROM media_file_artists mfa + JOIN media_file mf ON mfa.media_file_id = mf.id + WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders + GROUP BY mfa.artist_id, mf.library_id, mfa.role ), artist_total_counters AS ( SELECT mfa.artist_id, @@ -445,24 +438,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { ), combined_counters AS ( SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters - UNION + UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters - UNION + UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter ), library_artist_counters AS ( SELECT artist_id, library_id, json_group_object( - replace(role, '"', ''), + role, json_object('a', album_count, 'm', count, 's', size) ) AS counters FROM combined_counters GROUP BY artist_id, library_id ) UPDATE library_artist - SET stats = coalesce((SELECT counters FROM library_artist_counters lac - WHERE lac.artist_id = library_artist.artist_id + SET stats = coalesce((SELECT counters FROM library_artist_counters lac + WHERE lac.artist_id = library_artist.artist_id AND lac.library_id = library_artist.library_id), '{}') WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 002b82499..ab926c00d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(6))) + Expect(mr.CountAll()).To(Equal(int64(10))) }) It("returns songs ordered by lyrics with a specific title/artist", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 1007d84fe..f3cb4f3d0 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -69,10 +69,12 @@ var ( albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2}) + albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4}) testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, albumRadioactivity, + albumMultiDisc, } ) @@ -94,13 +96,22 @@ var ( Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`, }) songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"}) - testSongs = model.MediaFiles{ + // Multi-disc album tracks (intentionally out of order to test sorting) + songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + testSongs = model.MediaFiles{ songDayInALife, songComeTogether, songRadioactivity, songAntenna, songAntennaWithLyrics, songAntenna2, + songDisc2Track11, + songDisc1Track01, + songDisc2Track01, + songDisc1Track02, } ) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 15ae438d9..7fad93b1e 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() { }) }) }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 01eec0d02..b3f9e0c07 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, order_album_artist_name", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "title": "order_title", // To make sure these fields will be whitelisted "duration": "duration", diff --git a/persistence/user_repository.go b/persistence/user_repository.go index a7181b1a7..7baa8f6a8 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.db = db r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ + "id": idFilter(r.tableName), "password": invalidFilter(ctx), "name": r.withTableName(startsWithFilter), }) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 7c0707ecd..8abbf76a9 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() { Expect(user.Libraries[0].ID).To(Equal(1)) }) }) + + Describe("filters", func() { + It("qualifies id filter with table name", func() { + r := repo.(*userRepository) + qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) + sel := r.selectUserWithLibraries(qo) + query, _, err := r.toSQL(sel) + Expect(err).NotTo(HaveOccurred()) + Expect(query).To(ContainSubstring("user.id = {:p0}")) + }) + }) }) diff --git a/release/goreleaser.yml b/release/goreleaser.yml index f71c38f31..30c0d6f3b 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -83,6 +83,15 @@ nfpms: owner: navidrome group: navidrome + - src: release/linux/.package.rpm # contents: "rpm" + dst: /var/lib/navidrome/.package + type: "config|noreplace" + packager: rpm + - src: release/linux/.package.deb # contents: "deb" + dst: /var/lib/navidrome/.package + type: "config|noreplace" + packager: deb + scripts: preinstall: "release/linux/preinstall.sh" postinstall: "release/linux/postinstall.sh" diff --git a/release/linux/.package.deb b/release/linux/.package.deb new file mode 100644 index 000000000..811c85f42 --- /dev/null +++ b/release/linux/.package.deb @@ -0,0 +1 @@ +deb \ No newline at end of file diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm new file mode 100644 index 000000000..7c88ef3c0 --- /dev/null +++ b/release/linux/.package.rpm @@ -0,0 +1 @@ +rpm \ No newline at end of file diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index 9fc008446..7e595311e 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR" cp "$BINARY" "$MSI_OUTPUT_DIR" +# package type indicator file +echo "msi" > "$MSI_OUTPUT_DIR/.package" + # workaround for wixl WixVariable not working to override bmp locations cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index ec8b164e8..8ebba4632 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -69,6 +69,12 @@ + + + + + + @@ -81,6 +87,7 @@ + diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 8cde597cc..a6c3beb05 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -31,8 +31,12 @@ "mood": "Estado", "participants": "Participantes adicionais", "tags": "Etiquetas adicionais", - "mappedTags": "", - "rawTags": "Etiquetas en cru" + "mappedTags": "Etiquetas mapeadas", + "rawTags": "Etiquetas en cru", + "bitDepth": "Calidade de Bit", + "sampleRate": "Taxa de mostra", + "missing": "Falta", + "libraryName": "Biblioteca" }, "actions": { "addToQueue": "Ao final da cola", @@ -41,7 +45,8 @@ "shuffleAll": "Remexer todo", "download": "Descargar", "playNext": "A continuación", - "info": "Obter info" + "info": "Obter info", + "showInPlaylist": "Mostrar en Lista de reprodución" } }, "album": { @@ -70,7 +75,10 @@ "releaseType": "Tipo", "grouping": "Grupos", "media": "Multimedia", - "mood": "Estado" + "mood": "Estado", + "date": "Data de gravación", + "missing": "Falta", + "libraryName": "Biblioteca" }, "actions": { "playAll": "Reproducir", @@ -102,7 +110,8 @@ "rating": "Valoración", "genre": "Xénero", "size": "Tamaño", - "role": "Rol" + "role": "Rol", + "missing": "Falta" }, "roles": { "albumartist": "Artista do álbum |||| Artistas do álbum", @@ -117,7 +126,13 @@ "mixer": "Mistura |||| Mistura", "remixer": "Remezcla |||| Remezcla", "djmixer": "Mezcla DJs |||| Mezcla DJs", - "performer": "Intérprete |||| Intérpretes" + "performer": "Intérprete |||| Intérpretes", + "maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas" + }, + "actions": { + "shuffle": "Barallar", + "radio": "Radio", + "topSongs": "Cancións destacadas" } }, "user": { @@ -134,10 +149,12 @@ "currentPassword": "Contrasinal actual", "newPassword": "Novo contrasinal", "token": "Token", - "lastAccessAt": "Último acceso" + "lastAccessAt": "Último acceso", + "libraries": "Bibliotecas" }, "helperTexts": { - "name": "Os cambios no nome aplicaranse a próxima vez que accedas" + "name": "Os cambios no nome aplicaranse a próxima vez que accedas", + "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto" }, "notifications": { "created": "Creouse a usuaria", @@ -146,7 +163,12 @@ }, "message": { "listenBrainzToken": "Escribe o token de usuaria de ListenBrainz", - "clickHereForToken": "Preme aquí para obter o token" + "clickHereForToken": "Preme aquí para obter o token", + "selectAllLibraries": "Seleccionar todas as bibliotecas", + "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas" + }, + "validation": { + "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins" } }, "player": { @@ -190,11 +212,17 @@ "addNewPlaylist": "Crear \"%{name}\"", "export": "Exportar", "makePublic": "Facela Pública", - "makePrivate": "Facela Privada" + "makePrivate": "Facela Privada", + "saveQueue": "Salvar a Cola como Lista de reprodución", + "searchOrCreate": "Buscar listas ou escribe para crear nova…", + "pressEnterToCreate": "Preme Enter para crear nova lista", + "removeFromSelection": "Retirar da selección" }, "message": { "duplicate_song": "Engadir cancións duplicadas", - "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?" + "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", + "noPlaylistsFound": "Sen listas de reprodución", + "noPlaylists": "Sen listas dispoñibles" } }, "radio": { @@ -232,13 +260,68 @@ "fields": { "path": "Ruta", "size": "Tamaño", - "updatedAt": "Desapareceu o" + "updatedAt": "Desapareceu o", + "libraryName": "Biblioteca" }, "actions": { - "remove": "Retirar" + "remove": "Retirar", + "remove_all": "Retirar todo" }, "notifications": { "removed": "Ficheiro(s) faltantes retirados" + }, + "empty": "Sen ficheiros faltantes" + }, + "library": { + "name": "Biblioteca |||| Bibliotecas", + "fields": { + "name": "Nome", + "path": "Ruta", + "remotePath": "Ruta remota", + "lastScanAt": "Último escaneado", + "songCount": "Cancións", + "albumCount": "Álbums", + "artistCount": "Artistas", + "totalSongs": "Cancións", + "totalAlbums": "Álbums", + "totalArtists": "Artistas", + "totalFolders": "Cartafoles", + "totalFiles": "Ficheiros", + "totalMissingFiles": "Ficheiros que faltan", + "totalSize": "Tamaño total", + "totalDuration": "Duración", + "defaultNewUsers": "Por defecto para novas usuarias", + "createdAt": "Creada", + "updatedAt": "Actualizada" + }, + "sections": { + "basic": "Información básica", + "statistics": "Estatísticas" + }, + "actions": { + "scan": "Escanear Biblioteca", + "manageUsers": "Xestionar acceso das usuarias", + "viewDetails": "Ver detalles" + }, + "notifications": { + "created": "Biblioteca creada correctamente", + "updated": "Biblioteca actualizada correctamente", + "deleted": "Biblioteca eliminada correctamente", + "scanStarted": "Comezou o escaneo da biblioteca", + "scanCompleted": "Completouse o escaneado da biblioteca" + }, + "validation": { + "nameRequired": "Requírese un nome para a biblioteca", + "pathRequired": "Requírese unha ruta para a biblioteca", + "pathNotDirectory": "A ruta á biblioteca ten que ser un directorio", + "pathNotFound": "Non se atopa a ruta á biblioteca", + "pathNotAccessible": "A ruta á biblioteca non é accesible", + "pathInvalid": "Ruta non válida á biblioteca" + }, + "messages": { + "deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.", + "scanInProgress": "Escaneo en progreso…", + "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria" } } }, @@ -419,7 +502,11 @@ "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter", "remove_missing_title": "Retirar ficheiros que faltan", - "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións." + "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.", + "remove_all_missing_title": "Retirar todos os ficheiros que faltan", + "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" }, "menu": { "library": "Biblioteca", @@ -448,7 +535,13 @@ "albumList": "Álbums", "about": "Acerca de", "playlists": "Listas de reprodución", - "sharedPlaylists": "Listas compartidas" + "sharedPlaylists": "Listas compartidas", + "librarySelector": { + "allLibraries": "Todas as bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} Bibliotecas", + "selectLibraries": "Seleccionar Bibliotecas", + "none": "Ningunha" + } }, "player": { "playListsText": "Reproducir cola", @@ -485,6 +578,21 @@ "disabled": "Desactivado", "waiting": "Agardando" } + }, + "tabs": { + "about": "Sobre", + "config": "Configuración" + }, + "config": { + "configName": "Nome", + "environmentVariable": "Variable de entorno", + "currentValue": "Valor actual", + "configurationFile": "Ficheiro de configuración", + "exportToml": "Exportar configuración (TOML)", + "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" } }, "activity": { @@ -493,7 +601,10 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Servidor a funcionar", - "serverDown": "SEN CONEXIÓN" + "serverDown": "SEN CONEXIÓN", + "scanType": "Tipo", + "status": "Erro de escaneado", + "elapsedTime": "Tempo transcurrido" }, "help": { "title": "Atallos de Navidrome", @@ -508,5 +619,10 @@ "toggle_love": "Engadir canción a favoritas", "current_song": "Ir á Canción actual " } + }, + "nowPlaying": { + "title": "En reprodución", + "empty": "Sen reprodución", + "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } } \ No newline at end of file diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 9d1c2bb74..11fadb46b 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -400,8 +400,8 @@ }, "albumList": "Album", "about": "Info", - "playlists": "Scalette", - "sharedPlaylists": "Scalette Condivise" + "playlists": "Playlist", + "sharedPlaylists": "Playlist Condivise" }, "player": { "playListsText": "Coda", @@ -457,4 +457,4 @@ "current_song": "" } } -} \ No newline at end of file +} diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index a8b26df6d..6b81e02d8 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -12,6 +12,7 @@ "artist": "아티스트", "album": "앨범", "path": "파일 경로", + "libraryName": "라이브러리", "genre": "장르", "compilation": "컴필레이션", "year": "년", @@ -34,7 +35,8 @@ "participants": "추가 참가자", "tags": "추가 태그", "mappedTags": "매핑된 태그", - "rawTags": "원시 태그" + "rawTags": "원시 태그", + "missing": "누락" }, "actions": { "addToQueue": "나중에 재생", @@ -56,6 +58,7 @@ "playCount": "재생 횟수", "size": "크기", "name": "이름", + "libraryName": "라이브러리", "genre": "장르", "compilation": "컴필레이션", "year": "년", @@ -73,7 +76,8 @@ "releaseType": "유형", "grouping": "그룹", "media": "미디어", - "mood": "분위기" + "mood": "분위기", + "missing": "누락" }, "actions": { "playAll": "재생", @@ -105,7 +109,8 @@ "playCount": "재생 횟수", "rating": "평가", "genre": "장르", - "role": "역할" + "role": "역할", + "missing": "누락" }, "roles": { "albumartist": "앨범 아티스트 |||| 앨범 아티스트들", @@ -120,7 +125,13 @@ "mixer": "믹서 |||| 믹서들", "remixer": "리믹서 |||| 리믹서들", "djmixer": "DJ 믹서 |||| DJ 믹서들", - "performer": "공연자 |||| 공연자들" + "performer": "공연자 |||| 공연자들", + "maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들" + }, + "actions": { + "topSongs": "인기곡", + "shuffle": "셔플", + "radio": "라디오" } }, "user": { @@ -137,19 +148,26 @@ "changePassword": "비밀번호를 변경할까요?", "currentPassword": "현재 비밀번호", "newPassword": "새 비밀번호", - "token": "토큰" + "token": "토큰", + "libraries": "라이브러리" }, "helperTexts": { - "name": "이름 변경 사항은 다음 로그인 시에만 반영됨" + "name": "이름 변경 사항은 다음 로그인 시에만 반영됨", + "libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움" }, "notifications": { "created": "사용자 생성됨", "updated": "사용자 업데이트됨", "deleted": "사용자 삭제됨" }, + "validation": { + "librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함" + }, "message": { "listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.", - "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요" + "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요", + "selectAllLibraries": "모든 라이브러리 선택", + "adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음" } }, "player": { @@ -192,12 +210,18 @@ "selectPlaylist": "재생목록 선택:", "addNewPlaylist": "\"%{name}\" 만들기", "export": "내보내기", + "saveQueue": "재생목록에 대기열 저장", "makePublic": "공개 만들기", - "makePrivate": "비공개 만들기" + "makePrivate": "비공개 만들기", + "searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...", + "pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름", + "removeFromSelection": "선택에서 제거" }, "message": { "duplicate_song": "중복된 노래 추가", - "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?" + "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?", + "noPlaylistsFound": "재생목록을 찾을 수 없음", + "noPlaylists": "사용 가능한 재생 목록이 없음" } }, "radio": { @@ -238,14 +262,68 @@ "fields": { "path": "경로", "size": "크기", + "libraryName": "라이브러리", "updatedAt": "사라짐" }, "actions": { - "remove": "제거" + "remove": "제거", + "remove_all": "모두 제거" }, "notifications": { "removed": "누락된 파일이 제거되었음" } + }, + "library": { + "name": "라이브러리 |||| 라이브러리들", + "fields": { + "name": "이름", + "path": "경로", + "remotePath": "원격 경로", + "lastScanAt": "최근 스캔", + "songCount": "노래", + "albumCount": "앨범", + "artistCount": "아티스트", + "totalSongs": "노래", + "totalAlbums": "앨범", + "totalArtists": "아티스트", + "totalFolders": "폴더", + "totalFiles": "파일", + "totalMissingFiles": "누락된 파일", + "totalSize": "총 크기", + "totalDuration": "기간", + "defaultNewUsers": "신규 사용자 기본값", + "createdAt": "생성됨", + "updatedAt": "업데이트됨" + }, + "sections": { + "basic": "기본 정보", + "statistics": "통계" + }, + "actions": { + "scan": "라이브러리 스캔", + "manageUsers": "자용자 접속 관리", + "viewDetails": "상세 보기" + }, + "notifications": { + "created": "라이브러리가 성공적으로 생성됨", + "updated": "라이브러리가 성공적으로 업데이트됨", + "deleted": "라이브러리가 성공적으로 삭제됨", + "scanStarted": "라이브러리 스캔 스작됨", + "scanCompleted": "라이브러리 스캔 완료됨" + }, + "validation": { + "nameRequired": "라이브러리 이름이 필요함", + "pathRequired": "라이브러리 경로가 필요함", + "pathNotDirectory": "라이브러리 경로는 디렉터리여야 함", + "pathNotFound": "라이브러리 경로를 찾을 수 없음", + "pathNotAccessible": "라이브러리 경로에 접근할 수 없음", + "pathInvalid": "잘못된 라이브러리 경로" + }, + "messages": { + "deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.", + "scanInProgress": "스캔 진행 중...", + "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음" + } } }, "ra": { @@ -398,11 +476,15 @@ "transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.", "transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.", "songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음", + "noSimilarSongsFound": "비슷한 노래를 찾을 수 없음", + "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이 성공적으로 연결되었고 스크로블링이 활성화되었음", @@ -429,6 +511,12 @@ }, "menu": { "library": "라이브러리", + "librarySelector": { + "allLibraries": "모든 라이브러리 (%{count})", + "multipleLibraries": "%{selected} / %{total} 라이브러리", + "selectLibraries": "라이브러리 선택", + "none": "없음" + }, "settings": "설정", "version": "버전", "theme": "테마", @@ -491,6 +579,21 @@ "disabled": "비활성화", "waiting": "대기중" } + }, + "tabs": { + "about": "정보", + "config": "구성" + }, + "config": { + "configName": "구성 이름", + "environmentVariable": "환경 변수", + "currentValue": "현재 값", + "configurationFile": "구성 파일", + "exportToml": "구성 내보내기 (TOML)", + "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성", + "exportFailed": "구성 복사 실패", + "devFlagsHeader": "개발 플래그 (변경/삭제 가능)", + "devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음" } }, "activity": { @@ -499,7 +602,15 @@ "quickScan": "빠른 스캔", "fullScan": "전체 스캔", "serverUptime": "서버 가동 시간", - "serverDown": "오프라인" + "serverDown": "오프라인", + "scanType": "유형", + "status": "스캔 오류", + "elapsedTime": "경과 시간" + }, + "nowPlaying": { + "title": "현재 재생 중", + "empty": "재생 중인 콘텐츠 없음", + "minutesAgo": "%{smart_count} 분 전" }, "help": { "title": "Navidrome 단축키", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 4737cb33a..b6da47380 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -5,7 +5,7 @@ "name": "Nummer |||| Nummers", "fields": { "albumArtist": "Album Artiest", - "duration": "Lengte", + "duration": "Afspeelduur", "trackNumber": "Nummer #", "playCount": "Aantal keren afgespeeld", "title": "Titel", @@ -35,7 +35,8 @@ "rawTags": "Onbewerkte tags", "bitDepth": "Bit diepte", "sampleRate": "Sample waarde", - "missing": "Ontbrekend" + "missing": "Ontbrekend", + "libraryName": "Bibliotheek" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -44,7 +45,8 @@ "shuffleAll": "Shuffle alles", "download": "Downloaden", "playNext": "Volgende", - "info": "Meer info" + "info": "Meer info", + "showInPlaylist": "Toon in afspeellijst" } }, "album": { @@ -55,7 +57,7 @@ "duration": "Afspeelduur", "songCount": "Nummers", "playCount": "Aantal keren afgespeeld", - "name": "Naam", + "name": "Titel", "genre": "Genre", "compilation": "Compilatie", "year": "Jaar", @@ -65,9 +67,9 @@ "createdAt": "Datum toegevoegd", "size": "Grootte", "originalDate": "Origineel", - "releaseDate": "Uitgegeven", + "releaseDate": "Uitgave", "releases": "Uitgave |||| Uitgaven", - "released": "Uitgegeven", + "released": "Uitgave", "recordLabel": "Label", "catalogNum": "Catalogus nummer", "releaseType": "Type", @@ -75,7 +77,8 @@ "media": "Media", "mood": "Sfeer", "date": "Opnamedatum", - "missing": "Ontbrekend" + "missing": "Ontbrekend", + "libraryName": "Bibliotheek" }, "actions": { "playAll": "Afspelen", @@ -123,7 +126,13 @@ "mixer": "Mixer |||| Mixers", "remixer": "Remixer |||| Remixers", "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers" + "performer": "Performer |||| Performers", + "maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten" + }, + "actions": { + "shuffle": "Shuffle", + "radio": "Radio", + "topSongs": "Beste nummers" } }, "user": { @@ -132,7 +141,7 @@ "userName": "Gebruikersnaam", "isAdmin": "Is beheerder", "lastLoginAt": "Laatst ingelogd op", - "updatedAt": "Laatst gewijzigd op", + "updatedAt": "Laatst bijgewerkt op", "name": "Naam", "password": "Wachtwoord", "createdAt": "Aangemaakt op", @@ -140,19 +149,26 @@ "currentPassword": "Huidig wachtwoord", "newPassword": "Nieuw wachtwoord", "token": "Token", - "lastAccessAt": "Meest recente toegang" + "lastAccessAt": "Meest recente toegang", + "libraries": "Bibliotheken" }, "helperTexts": { - "name": "Naamswijziging wordt pas zichtbaar bij de volgende login" + "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", + "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken" }, "notifications": { "created": "Aangemaakt door gebruiker", - "updated": "Gewijzigd door gebruiker", - "deleted": "Gewist door gebruiker" + "updated": "Bijgewerkt door gebruiker", + "deleted": "Gebruiker verwijderd" }, "message": { "listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.", - "clickHereForToken": "Klik hier voor je token" + "clickHereForToken": "Klik hier voor je token", + "selectAllLibraries": "Selecteer alle bibliotheken", + "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" + }, + "validation": { + "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers" } }, "player": { @@ -181,10 +197,10 @@ "name": "Afspeellijst |||| Afspeellijsten", "fields": { "name": "Titel", - "duration": "Lengte", + "duration": "Afspeelduur", "ownerName": "Eigenaar", "public": "Publiek", - "updatedAt": "Laatst gewijzigd op", + "updatedAt": "Laatst bijgewerkt op", "createdAt": "Aangemaakt op", "songCount": "Nummers", "comment": "Commentaar", @@ -197,11 +213,16 @@ "export": "Exporteer", "makePublic": "Openbaar maken", "makePrivate": "Privé maken", - "saveQueue": "Bewaar wachtrij als playlist" + "saveQueue": "Bewaar wachtrij als playlist", + "searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...", + "pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken", + "removeFromSelection": "Verwijder van selectie" }, "message": { "duplicate_song": "Dubbele nummers toevoegen", - "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?" + "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?", + "noPlaylistsFound": "Geen playlists gevonden", + "noPlaylists": "Geen playlists beschikbaar" } }, "radio": { @@ -210,8 +231,8 @@ "name": "Naam", "streamUrl": "Stream URL", "homePageUrl": "Hoofdpagina URL", - "updatedAt": "Geüpdate op", - "createdAt": "Gecreëerd op" + "updatedAt": "Bijgewerkt op", + "createdAt": "Aangemaakt op" }, "actions": { "playNow": "Speel nu" @@ -229,8 +250,8 @@ "visitCount": "Bezocht", "format": "Formaat", "maxBitRate": "Max. bitrate", - "updatedAt": "Geüpdatet op", - "createdAt": "Gecreëerd op", + "updatedAt": "Bijgewerkt op", + "createdAt": "Aangemaakt op", "downloadable": "Downloads toestaan?" } }, @@ -239,7 +260,8 @@ "fields": { "path": "Pad", "size": "Grootte", - "updatedAt": "Verdwenen op" + "updatedAt": "Verdwenen op", + "libraryName": "Bibliotheek" }, "actions": { "remove": "Verwijder", @@ -249,6 +271,58 @@ "removed": "Ontbrekende bestanden verwijderd" }, "empty": "Geen ontbrekende bestanden" + }, + "library": { + "name": "Bibliotheek |||| Bibliotheken", + "fields": { + "name": "Naam", + "path": "Pad", + "remotePath": "Extern pad", + "lastScanAt": "Laatste scan", + "songCount": "Nummers", + "albumCount": "Albums", + "artistCount": "Artiesten", + "totalSongs": "Nummers", + "totalAlbums": "Albums", + "totalArtists": "Artiesten", + "totalFolders": "Mappen", + "totalFiles": "Bestanden", + "totalMissingFiles": "Ontbrekende bestanden", + "totalSize": "Totale bestandsgrootte", + "totalDuration": "Afspeelduur", + "defaultNewUsers": "Standaard voor nieuwe gebruikers", + "createdAt": "Aangemaakt", + "updatedAt": "Bijgewerkt" + }, + "sections": { + "basic": "Basisinformatie", + "statistics": "Statistieken" + }, + "actions": { + "scan": "Scan bibliotheek", + "manageUsers": "Beheer gebruikerstoegang", + "viewDetails": "Bekijk details" + }, + "notifications": { + "created": "Bibliotheek succesvol aangemaakt", + "updated": "Bibliotheek succesvol bijgewerkt", + "deleted": "Bibliotheek succesvol verwijderd", + "scanStarted": "Bibliotheekscan is gestart", + "scanCompleted": "Bibliotheekscan is voltooid" + }, + "validation": { + "nameRequired": "Bibliotheek naam is vereist", + "pathRequired": "Pad naar bibliotheek is vereist", + "pathNotDirectory": "Pad naar bibliotheek moet een map zijn", + "pathNotFound": "Pad naar bibliotheek niet gevonden", + "pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk", + "pathInvalid": "Ongeldig pad naar bibliotheek" + }, + "messages": { + "deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.", + "scanInProgress": "Scan is bezig...", + "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen" + } } }, "ra": { @@ -430,7 +504,9 @@ "remove_missing_title": "Verwijder ontbrekende bestanden", "remove_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.", "remove_all_missing_title": "Verwijder alle ontbrekende bestanden", - "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." + "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" }, "menu": { "library": "Bibliotheek", @@ -459,7 +535,13 @@ "albumList": "Albums", "about": "Over", "playlists": "Afspeellijsten", - "sharedPlaylists": "Gedeelde afspeellijsten" + "sharedPlaylists": "Gedeelde afspeellijsten", + "librarySelector": { + "allLibraries": "Alle bibliotheken (%{count})", + "multipleLibraries": "%{selected} van %{total} bibliotheken", + "selectLibraries": "Selecteer bibliotheken", + "none": "Geen" + } }, "player": { "playListsText": "Wachtrij", @@ -468,7 +550,7 @@ "notContentText": "Geen muziek", "clickToPlayText": "Klik om af te spelen", "clickToPauseText": "Klik om te pauzeren", - "nextTrackText": "Volgende", + "nextTrackText": "Volgend nummer", "previousTrackText": "Vorige", "reloadText": "Herladen", "volumeText": "Volume", @@ -496,11 +578,26 @@ "disabled": "Uitgeschakeld", "waiting": "Wachten" } + }, + "tabs": { + "about": "Over", + "config": "Configuratie" + }, + "config": { + "configName": "Config Naam", + "environmentVariable": "Omgevingsvariabele", + "currentValue": "Huidige waarde", + "configurationFile": "Configuratiebestand", + "exportToml": "Exporteer configuratie (TOML)", + "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" } }, "activity": { "title": "Activiteit", - "totalScanned": "Totaal gescande folders", + "totalScanned": "Totaal gescande mappen", "quickScan": "Snelle scan", "fullScan": "Volledige scan", "serverUptime": "Server uptime", @@ -522,5 +619,10 @@ "toggle_love": "Voeg toe aan favorieten", "current_song": "Ga naar huidig nummer" } + }, + "nowPlaying": { + "title": "Speelt nu", + "empty": "Er wordt niets afgespeed", + "minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden" } } \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 2f96f4958..65d51860f 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -26,7 +26,17 @@ "bpm": "BPM", "playDate": "เล่นล่าสุด", "channels": "ช่อง", - "createdAt": "เพิ่มเมื่อ" + "createdAt": "เพิ่มเมื่อ", + "grouping": "จัดกลุ่ม", + "mood": "อารมณ์", + "participants": "ผู้มีส่วนร่วม", + "tags": "แทกเพิ่มเติม", + "mappedTags": "แมพแทก", + "rawTags": "แทกเริ่มต้น", + "bitDepth": "Bit depth", + "sampleRate": "แซมเปิ้ลเรต", + "missing": "หายไป", + "libraryName": "ห้องสมุด" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -35,7 +45,8 @@ "shuffleAll": "สุ่มทั้งหมด", "download": "ดาวน์โหลด", "playNext": "เล่นถัดไป", - "info": "ดูรายละเอียด" + "info": "ดูรายละเอียด", + "showInPlaylist": "แสดงในเพลย์ลิสต์" } }, "album": { @@ -58,7 +69,16 @@ "originalDate": "วันที่เริ่ม", "releaseDate": "เผยแพร่เมื่อ", "releases": "เผยแพร่ |||| เผยแพร่", - "released": "เผยแพร่เมื่อ" + "released": "เผยแพร่เมื่อ", + "recordLabel": "ป้าย", + "catalogNum": "หมายเลขแคตาล็อก", + "releaseType": "ประเภท", + "grouping": "จัดกลุ่ม", + "media": "มีเดีย", + "mood": "อารมณ์", + "date": "บันทึกเมื่อ", + "missing": "หายไป", + "libraryName": "ห้องสมุด" }, "actions": { "playAll": "เล่นทั้งหมด", @@ -89,7 +109,30 @@ "playCount": "เล่นแล้ว", "rating": "ความนิยม", "genre": "ประเภท", - "size": "ขนาด" + "size": "ขนาด", + "role": "Role", + "missing": "หายไป" + }, + "roles": { + "albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม", + "artist": "ศิลปิน |||| ศิลปิน", + "composer": "ผู้แต่ง |||| ผู้แต่ง", + "conductor": "คอนดักเตอร์ |||| คอนดักเตอร์", + "lyricist": "เนื้อเพลง |||| เนื้อเพลง", + "arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ", + "producer": "ผู้จัด |||| ผู้จัด", + "director": "ไดเรกเตอร์ |||| ไดเรกเตอร์", + "engineer": "วิศวกร |||| วิศวกร", + "mixer": "มิกเซอร์ |||| มิกเซอร์", + "remixer": "รีมิกเซอร์ |||| รีมิกเซอร์", + "djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์", + "performer": "ผู้เล่น |||| ผู้เล่น", + "maincredit": "ศิลปิน |||| ศิลปิน" + }, + "actions": { + "shuffle": "เล่นสุ่ม", + "radio": "วิทยุ", + "topSongs": "เพลงยอดนิยม" } }, "user": { @@ -106,10 +149,12 @@ "currentPassword": "รหัสผ่านปัจจุบัน", "newPassword": "รหัสผ่านใหม่", "token": "โทเคน", - "lastAccessAt": "เข้าใช้ล่าสุด" + "lastAccessAt": "เข้าใช้ล่าสุด", + "libraries": "ห้องสมุด" }, "helperTexts": { - "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป" + "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", + "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น" }, "notifications": { "created": "สร้างชื่อผู้ใช้", @@ -118,7 +163,12 @@ }, "message": { "listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ", - "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ" + "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ", + "selectAllLibraries": "เลือกห้องสมุดทั้งหมด", + "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" + }, + "validation": { + "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล" } }, "player": { @@ -162,11 +212,17 @@ "addNewPlaylist": "สร้าง \"%{name}\"", "export": "ส่งออก", "makePublic": "ทำเป็นสาธารณะ", - "makePrivate": "ทำเป็นส่วนตัว" + "makePrivate": "ทำเป็นส่วนตัว", + "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", + "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", + "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", + "removeFromSelection": "เอาออกจากที่เลือกไว้" }, "message": { "duplicate_song": "เพิ่มเพลงซ้ำ", - "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม" + "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", + "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", + "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" } }, "radio": { @@ -198,6 +254,75 @@ "createdAt": "สร้างเมื่อ", "downloadable": "อนุญาตให้ดาวโหลด?" } + }, + "missing": { + "name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป", + "fields": { + "path": "พาร์ท", + "size": "ขนาด", + "updatedAt": "หายไปจาก", + "libraryName": "ห้องสมุด" + }, + "actions": { + "remove": "เอาออก", + "remove_all": "เอาออกทั้งหมด" + }, + "notifications": { + "removed": "เอาไฟล์ที่หายไปออกแล้ว" + }, + "empty": "ไม่มีไฟล์หาย" + }, + "library": { + "name": "ห้องสมุด |||| ห้องสมุด", + "fields": { + "name": "ชื่อ", + "path": "พาร์ท", + "remotePath": "รีโมทพาร์ท", + "lastScanAt": "สแกนล่าสุด", + "songCount": "เพลง", + "albumCount": "อัลบัม", + "artistCount": "ศิลปิน", + "totalSongs": "เพลง", + "totalAlbums": "อัลบัม", + "totalArtists": "ศิลปิน", + "totalFolders": "แฟ้ม", + "totalFiles": "ไฟล์", + "totalMissingFiles": "ไฟล์ที่หายไป", + "totalSize": "ขนาดทั้งหมด", + "totalDuration": "ความยาว", + "defaultNewUsers": "ค่าเริ่มต้นผู้ใช้ใหม่", + "createdAt": "สร้าง", + "updatedAt": "อัพเดท" + }, + "sections": { + "basic": "ข้อมูลเบื้องต้น", + "statistics": "สถิติ" + }, + "actions": { + "scan": "สแกนห้องสมุด", + "manageUsers": "ตั้งค่าการเข้าถึง", + "viewDetails": "ดูรายละเอียด" + }, + "notifications": { + "created": "สร้างห้องสมุดเรียบร้อย", + "updated": "อัพเดทห้องสมุดเรียบร้อย", + "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว", + "scanStarted": "เริ่มสแกนห้องสมุด", + "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว" + }, + "validation": { + "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง", + "pathRequired": "ต้องใส่พาร์ทของห้องสมุด", + "pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม", + "pathNotFound": "ไม่เจอพาร์ทของห้องสมุด", + "pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด", + "pathInvalid": "พาร์ทห้องสมุดไม่ถูก" + }, + "messages": { + "deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด", + "scanInProgress": "กำลังสแกน...", + "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้" + } } }, "ra": { @@ -375,7 +500,13 @@ "shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}", "shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด", "downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter" + "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter", + "remove_missing_title": "ลบรายการไฟล์ที่หายไป", + "remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", + "remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด", + "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", + "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", + "noTopSongsFound": "ไม่พบเพลงยอดนิยม" }, "menu": { "library": "ห้องสมุดเพลง", @@ -404,7 +535,13 @@ "albumList": "อัลบั้ม", "about": "เกี่ยวกับ", "playlists": "เพลย์ลิสต์", - "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน" + "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน", + "librarySelector": { + "allLibraries": "ห้องสมุด (%{count}) ห้อง", + "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด", + "selectLibraries": "เลือกห้องสมุด", + "none": "ไม่มี" + } }, "player": { "playListsText": "คิวเล่น", @@ -441,6 +578,21 @@ "disabled": "ปิดการทำงาน", "waiting": "รอ" } + }, + "tabs": { + "about": "เกี่ยวกับ", + "config": "การตั้งค่า" + }, + "config": { + "configName": "ชื่อการตั้งค่า", + "environmentVariable": "ค่าทั่วไป", + "currentValue": "ค่าปัจจุบัน", + "configurationFile": "ไฟล์การตั้งค่า", + "exportToml": "นำออกการตั้งค่า (TOML)", + "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", + "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", + "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", + "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง" } }, "activity": { @@ -449,7 +601,10 @@ "quickScan": "สแกนแบบเร็ว", "fullScan": "สแกนทั้งหมด", "serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน", - "serverDown": "ออฟไลน์" + "serverDown": "ออฟไลน์", + "scanType": "ประเภท", + "status": "สแกนผิดพลาด", + "elapsedTime": "เวลาที่ใช้" }, "help": { "title": "คีย์ลัด Navidrome", @@ -464,5 +619,10 @@ "toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด", "current_song": "ไปยังเพลงปัจจุบัน" } + }, + "nowPlaying": { + "title": "กำลังเล่น", + "empty": "ไม่มีเพลงเล่น", + "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } } \ No newline at end of file diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 3d6bbd268..7d8ce2872 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -1,463 +1,630 @@ { - "languageName": "繁體中文", - "resources": { - "song": { - "name": "歌曲 |||| 歌曲", - "fields": { - "albumArtist": "專輯藝人", - "duration": "長度", - "trackNumber": "#", - "playCount": "播放次數", - "title": "標題", - "artist": "藝人", - "album": "專輯", - "path": "文件路徑", - "genre": "類型", - "compilation": "合輯", - "year": "發行年份", - "size": "檔案大小", - "updatedAt": "更新於", - "bitRate": "位元率", - "discSubtitle": "字幕", - "starred": "收藏", - "comment": "註解", - "rating": "評分", - "quality": "品質", - "bpm": "BPM", - "playDate": "上次播放", - "channels": "聲道", - "createdAt": "創建於" - }, - "actions": { - "addToQueue": "加入至播放佇列", - "playNow": "立即播放", - "addToPlaylist": "加入至播放清單", - "shuffleAll": "全部隨機播放", - "download": "下載", - "playNext": "下一首播放", - "info": "取得資訊" - } - }, - "album": { - "name": "專輯 |||| 專輯", - "fields": { - "albumArtist": "專輯藝人", - "artist": "藝人", - "duration": "長度", - "songCount": "歌曲數量", - "playCount": "播放次數", - "name": "名稱", - "genre": "類型", - "compilation": "合輯", - "year": "發行年份", - "updatedAt": "更新於", - "comment": "註解", - "rating": "評分", - "createdAt": "創建於", - "size": "檔案大小", - "originalDate": "原始日期", - "releaseDate": "發行日期", - "releases": "發行", - "released": "已發行" - }, - "actions": { - "playAll": "立即播放", - "playNext": "下首播放", - "addToQueue": "加入至播放佇列", - "shuffle": "隨機播放", - "addToPlaylist": "加入播放清單", - "download": "下載", - "info": "取得資訊", - "share": "分享" - }, - "lists": { - "all": "所有", - "random": "隨機", - "recentlyAdded": "最近加入", - "recentlyPlayed": "最近播放", - "mostPlayed": "最多播放的", - "starred": "收藏", - "topRated": "最高評分" - } - }, - "artist": { - "name": "藝人 |||| 藝人", - "fields": { - "name": "名稱", - "albumCount": "專輯數", - "songCount": "歌曲數", - "playCount": "播放次數", - "rating": "評分", - "genre": "類型", - "size": "檔案大小" - } - }, - "user": { - "name": "使用者 |||| 使用者", - "fields": { - "userName": "使用者名稱", - "isAdmin": "是否管理員", - "lastLoginAt": "上次登入", - "lastAccessAt": "上此訪問", - "updatedAt": "更新於", - "name": "名稱", - "password": "密碼", - "createdAt": "創建於", - "changePassword": "變更密碼?", - "currentPassword": "現在的密碼", - "newPassword": "新密碼", - "token": "權杖" - }, - "helperTexts": { - "name": "你的名稱會在下次登入時生效" - }, - "notifications": { - "created": "使用者已創建", - "updated": "使用者已更新", - "deleted": "使用者已刪除" - }, - "message": { - "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", - "clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖" - } - }, - "player": { - "name": "用戶端 |||| 用戶端", - "fields": { - "name": "名稱", - "transcodingId": "轉碼", - "maxBitRate": "最大位元率", - "client": "用戶端", - "userName": "使用者名稱", - "lastSeen": "上次瀏覽", - "reportRealPath": "回報實際路徑", - "scrobbleEnabled": "傳送音樂記錄至外部服務" - } - }, - "transcoding": { - "name": "轉碼 |||| 轉碼", - "fields": { - "name": "名稱", - "targetFormat": "目標格式", - "defaultBitRate": "預設位元率", - "command": "命令" - } - }, - "playlist": { - "name": "播放清單 |||| 播放清單", - "fields": { - "name": "名稱", - "duration": "長度", - "ownerName": "擁有者", - "public": "公開", - "updatedAt": "更新於", - "createdAt": "創建於", - "songCount": "歌曲數", - "comment": "註解", - "sync": "自動導入", - "path": "導入" - }, - "actions": { - "selectPlaylist": "選擇播放清單", - "addNewPlaylist": "創建 %{name}", - "export": "導出", - "makePublic": "設為公開", - "makePrivate": "設為私人" - }, - "message": { - "duplicate_song": "加入重複的歌曲", - "song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?" - } - }, - "radio": { - "name": "電台", - "fields": { - "name": "名稱", - "streamUrl": "串流網址", - "homePageUrl": "首頁網址", - "updatedAt": "更新於", - "createdAt": "創建於" - }, - "actions": { - "playNow": "立即播放" - } - }, - "share": { - "name": "分享", - "fields": { - "username": "使用者名稱", - "url": "網址", - "description": "描述", - "contents": "內容", - "expiresAt": "過期時間", - "lastVisitedAt": "上次訪問時間", - "visitCount": "訪問次數", - "format": "格式", - "maxBitRate": "最大位元率", - "updatedAt": "更新於", - "createdAt": "創建於", - "downloadable": "可下載" - }, - "notifications": {}, - "actions": {} - } + "languageName": "繁體中文", + "resources": { + "song": { + "name": "歌曲 |||| 歌曲", + "fields": { + "albumArtist": "專輯藝人", + "duration": "長度", + "trackNumber": "#", + "playCount": "播放次數", + "title": "標題", + "artist": "藝人", + "album": "專輯", + "path": "檔案路徑", + "libraryName": "媒體庫", + "genre": "曲風", + "compilation": "合輯", + "year": "發行年份", + "size": "檔案大小", + "updatedAt": "更新於", + "bitRate": "位元率", + "bitDepth": "位元深度", + "sampleRate": "取樣率", + "channels": "聲道", + "discSubtitle": "光碟副標題", + "starred": "收藏", + "comment": "註解", + "rating": "評分", + "quality": "品質", + "bpm": "BPM", + "playDate": "上次播放", + "createdAt": "建立於", + "grouping": "分組", + "mood": "情緒", + "participants": "其他參與人員", + "tags": "額外標籤", + "mappedTags": "分類後標籤", + "rawTags": "原始標籤", + "missing": "遺失" + }, + "actions": { + "addToQueue": "加入至播放佇列", + "playNow": "立即播放", + "addToPlaylist": "加入至播放清單", + "showInPlaylist": "在播放清單中顯示", + "shuffleAll": "全部隨機播放", + "download": "下載", + "playNext": "下一首播放", + "info": "取得資訊" + } }, - "ra": { - "auth": { - "welcome1": "感謝您安裝 Navidrome!", - "welcome2": "開始前,請創建一個管理員帳戶", - "confirmPassword": "確認密碼", - "buttonCreateAdmin": "創建管理員", - "auth_check_error": "請登入以訪問更多內容", - "user_menu": "配置", - "username": "使用者名稱", - "password": "密碼", - "sign_in": "登入", - "sign_in_error": "驗證失敗,請重試", - "logout": "登出" - }, - "validation": { - "invalidChars": "請使用字母和數字", - "passwordDoesNotMatch": "密碼不相符", - "required": "必填", - "minLength": "必須不少於 %{min} 個字元", - "maxLength": "必須不多於 %{max} 個字元", - "minValue": "必須不小於 %{min}", - "maxValue": "必須不大於 %{max}", - "number": "必須為數字", - "email": "必須是有效的電子郵件", - "oneOf": "必須為: %{options}其中一項", - "regex": "必須符合指定的格式(正規表達式):%{pattern}", - "unique": "必須是唯一的", - "url": "網址" - }, - "action": { - "add_filter": "加入篩選", - "add": "加入", - "back": "返回", - "bulk_actions": "選中 %{smart_count} 項", - "cancel": "取消", - "clear_input_value": "清除", - "clone": "複製", - "confirm": "確認", - "create": "創建", - "delete": "刪除", - "edit": "編輯", - "export": "匯出", - "list": "列表", - "refresh": "重新整理", - "remove_filter": "清除此條件", - "remove": "清除", - "save": "保存", - "search": "搜尋", - "show": "顯示", - "sort": "排序", - "undo": "撤銷", - "expand": "展開", - "close": "關閉", - "open_menu": "打開選單", - "close_menu": "關閉選單", - "unselect": "未選擇", - "skip": "略過", - "bulk_actions_mobile": "%{smart_count}", - "share": "分享", - "download": "下載" - }, - "boolean": { - "true": "是", - "false": "否" - }, - "page": { - "create": "創建 %{name}", - "dashboard": "儀表板", - "edit": "%{name} #%{id}", - "error": "發生錯誤", - "list": "%{name}", - "loading": "載入中", - "not_found": "未發現", - "show": "%{name} #%{id}", - "empty": "還沒有 %{name}。", - "invite": "你要創建一個嗎?" - }, - "input": { - "file": { - "upload_several": "拖拽多個文件上傳或點擊選擇一個", - "upload_single": "拖拽單個文件上傳或點擊選擇一個" - }, - "image": { - "upload_several": "拖拽多個圖片上傳或點擊選擇一個", - "upload_single": "拖拽單個圖片上傳或點擊選擇一個" - }, - "references": { - "all_missing": "未找到參考數據", - "many_missing": "至少有一條參考數據不再可用", - "single_missing": "關聯的參考數據不再可用" - }, - "password": { - "toggle_visible": "隱藏密碼", - "toggle_hidden": "顯示密碼" - } - }, - "message": { - "about": "關於", - "are_you_sure": "確定進行此操作?", - "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除 %{smart_count} 項?", - "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}", - "delete_content": "您確定要刪除該項目?", - "delete_title": "刪除 %{name} #%{id}", - "details": "詳細資訊", - "error": "發生一個用戶端錯誤,您的請求無法完成", - "invalid_form": "提交內容無效,請檢查錯誤", - "loading": "正在載入頁面,請稍候", - "no": "否", - "not_found": "您輸入的連結格式不對或連結遺失", - "yes": "是", - "unsaved_changes": "某些更改尚未保存,您確定要離開此頁面嗎?" - }, - "navigation": { - "no_results": "無內容", - "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁", - "page_out_of_boundaries": "頁碼 %{page} 超出邊界", - "page_out_from_end": "已經最後一頁", - "page_out_from_begin": "已經是第一頁", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "每頁行數:", - "next": "下一頁", - "prev": "上一頁", - "skip_nav": "跳過" - }, - "notification": { - "updated": "項已更新 |||| %{smart_count} 項已更新", - "created": "項已創建", - "deleted": "項已刪除 |||| %{smart_count} 項已刪除", - "bad_item": "不確定的項", - "item_doesnt_exist": "項不存在", - "http_error": "伺服器通訊錯誤", - "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊", - "i18n_error": "無法載入所選語言", - "canceled": "操作已取消", - "logged_out": "您的會話已結束,請重新登入", - "new_version": "發現新版本!請重新整理視窗" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "顯示欄目", - "layout": "版面", - "grid": "框格", - "table": "表格" - } + "album": { + "name": "專輯 |||| 專輯", + "fields": { + "albumArtist": "專輯藝人", + "artist": "藝人", + "duration": "長度", + "songCount": "歌曲數", + "playCount": "播放次數", + "size": "檔案大小", + "name": "名稱", + "libraryName": "媒體庫", + "genre": "曲風", + "compilation": "合輯", + "year": "發行年份", + "date": "錄製日期", + "originalDate": "原始日期", + "releaseDate": "發行日期", + "releases": "發行", + "released": "已發行", + "updatedAt": "更新於", + "comment": "註解", + "rating": "評分", + "createdAt": "建立於", + "recordLabel": "唱片公司", + "catalogNum": "目錄編號", + "releaseType": "發行類型", + "grouping": "分組", + "media": "媒體類型", + "mood": "情緒", + "missing": "遺失" + }, + "actions": { + "playAll": "播放全部", + "playNext": "下一首播放", + "addToQueue": "加入至播放佇列", + "share": "分享", + "shuffle": "隨機播放", + "addToPlaylist": "加入至播放清單", + "download": "下載", + "info": "取得資訊" + }, + "lists": { + "all": "所有", + "random": "隨機", + "recentlyAdded": "最近加入", + "recentlyPlayed": "最近播放", + "mostPlayed": "最常播放", + "starred": "收藏", + "topRated": "最高評分" + } }, - "message": { - "note": "註解", - "transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。", - "transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。", - "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單", - "noPlaylistsAvailable": "沒有可用的播放清單", - "delete_user_title": "刪除使用者 %{name}", - "delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?", - "notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知", - "notifications_not_available": "此瀏覽器不支援桌面通知", - "lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄", - "lastfmLinkFailure": "Last.fm 無法連接", - "lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄", - "lastfmUnlinkFailure": "Last.fm 無法取消連接", - "openIn": { - "lastfm": "在 Last.fm 打開", - "musicbrainz": "在 MusicBrainz 打開" - }, - "lastfmLink": "繼續閱讀…", - "listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄", - "listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接", - "downloadOriginalFormat": "下載原始格式", - "shareOriginalFormat": "分享原始格式", - "shareDialogTitle": "分享", - "shareBatchDialogTitle": "批次分享", - "shareSuccess": "分享成功", - "shareFailure": "分享失敗", - "downloadDialogTitle": "下載", - "shareCopyToClipboard": "複製到剪貼簿" + "artist": { + "name": "藝人 |||| 藝人", + "fields": { + "name": "名稱", + "albumCount": "專輯數", + "songCount": "歌曲數", + "size": "檔案大小", + "playCount": "播放次數", + "rating": "評分", + "genre": "曲風", + "role": "參與角色", + "missing": "遺失" + }, + "roles": { + "albumartist": "專輯藝人 |||| 專輯藝人", + "artist": "藝人 |||| 藝人", + "composer": "作曲 |||| 作曲", + "conductor": "指揮 |||| 指揮", + "lyricist": "作詞 |||| 作詞", + "arranger": "編曲 |||| 編曲", + "producer": "製作人 |||| 製作人", + "director": "導演 |||| 導演", + "engineer": "工程師 |||| 工程師", + "mixer": "混音師 |||| 混音師", + "remixer": "重混師 |||| 重混師", + "djmixer": "DJ 混音師 |||| DJ 混音師", + "performer": "表演者 |||| 表演者", + "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人" + }, + "actions": { + "topSongs": "熱門歌曲", + "shuffle": "隨機播放", + "radio": "電台" + } }, - "menu": { - "library": "音樂庫", - "settings": "設定", - "version": "版本", - "theme": "主題", - "personal": { - "name": "個人化", - "options": { - "theme": "主題", - "language": "語言", - "defaultView": "預設畫面", - "desktop_notifications": "桌面通知", - "lastfmScrobbling": "啟用 Last.fm 音樂記錄", - "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", - "replaygain": "重播增益", - "preAmp": "前置放大器 (dB)", - "gain": { - "none": "無", - "album": "專輯增益", - "track": "曲目增益" - } - } - }, - "albumList": "專輯", - "about": "關於", - "playlists": "播放清單", - "sharedPlaylists": "分享的播放清單" + "user": { + "name": "使用者 |||| 使用者", + "fields": { + "userName": "使用者名稱", + "isAdmin": "管理員", + "lastLoginAt": "上次登入", + "lastAccessAt": "上次存取", + "updatedAt": "更新於", + "name": "名稱", + "password": "密碼", + "createdAt": "建立於", + "changePassword": "變更密碼?", + "currentPassword": "目前密碼", + "newPassword": "新密碼", + "token": "權杖", + "libraries": "媒體庫" + }, + "helperTexts": { + "name": "您的名稱會在下次登入時生效", + "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫" + }, + "notifications": { + "created": "使用者已建立", + "updated": "使用者已更新", + "deleted": "使用者已刪除" + }, + "validation": { + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" + }, + "message": { + "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", + "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖", + "selectAllLibraries": "選取全部媒體庫", + "adminAutoLibraries": "管理員預設可存取所有媒體庫" + } }, "player": { - "playListsText": "播放佇列", - "openText": "打開", - "closeText": "關閉", - "notContentText": "沒有音樂", - "clickToPlayText": "點擊播放", - "clickToPauseText": "點擊暫停", - "nextTrackText": "下一首", - "previousTrackText": "上一首", - "reloadText": "重新播放", - "volumeText": "音量", - "toggleLyricText": "切換歌詞", - "toggleMiniModeText": "最小化", - "destroyText": "關閉", - "downloadText": "下載", - "removeAudioListsText": "清空播放佇列", - "clickToDeleteText": "點擊刪除 %{name}", - "emptyLyricText": "無歌詞", - "playModeText": { - "order": "順序播放", - "orderLoop": "列表循環", - "singleLoop": "單曲循環", - "shufflePlay": "隨機播放" - } + "name": "播放器 |||| 播放器", + "fields": { + "name": "名稱", + "transcodingId": "轉碼", + "maxBitRate": "最大位元率", + "client": "客戶端", + "userName": "使用者名稱", + "lastSeen": "上次上線", + "reportRealPath": "回報實際路徑", + "scrobbleEnabled": "傳送音樂記錄至外部服務" + } }, - "about": { - "links": { - "homepage": "主頁", - "source": "原始碼", - "featureRequests": "功能請求" - } + "transcoding": { + "name": "轉碼 |||| 轉碼", + "fields": { + "name": "名稱", + "targetFormat": "目標格式", + "defaultBitRate": "預設位元率", + "command": "指令" + } }, - "activity": { - "title": "運作狀況", - "totalScanned": "已完成掃描的目錄", - "quickScan": "快速掃描", - "fullScan": "完全掃描", - "serverUptime": "伺服器已運作時間", - "serverDown": "伺服器離線" + "playlist": { + "name": "播放清單 |||| 播放清單", + "fields": { + "name": "名稱", + "duration": "長度", + "ownerName": "擁有者", + "public": "公開", + "updatedAt": "更新於", + "createdAt": "建立於", + "songCount": "歌曲數", + "comment": "註解", + "sync": "自動匯入", + "path": "匯入來源" + }, + "actions": { + "selectPlaylist": "選取播放清單:", + "addNewPlaylist": "建立「%{name}」", + "export": "匯出", + "saveQueue": "將播放佇列儲存到播放清單", + "makePublic": "設為公開", + "makePrivate": "設為私人", + "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", + "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", + "removeFromSelection": "移除選取項目" + }, + "message": { + "duplicate_song": "加入重複的歌曲", + "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", + "noPlaylistsFound": "找不到播放清單", + "noPlaylists": "暫無播放清單" + } }, - "help": { - "title": "Navidrome 快捷鍵", - "hotkeys": { - "show_help": "顯示此幫助", - "toggle_menu": "顯示/隱藏選單側欄", - "toggle_play": "播放/暫停", - "prev_song": "上一首歌", - "next_song": "下一首歌", - "vol_up": "提高音量", - "vol_down": "降低音量", - "toggle_love": "添加或移除星標", - "current_song": "目前歌曲" - } + "radio": { + "name": "電台 |||| 電台", + "fields": { + "name": "名稱", + "streamUrl": "串流網址", + "homePageUrl": "首頁網址", + "updatedAt": "更新於", + "createdAt": "建立於" + }, + "actions": { + "playNow": "立即播放" + } + }, + "share": { + "name": "分享 |||| 分享", + "fields": { + "username": "分享者", + "url": "網址", + "description": "描述", + "downloadable": "允許下載?", + "contents": "內容", + "expiresAt": "過期時間", + "lastVisitedAt": "上次造訪時間", + "visitCount": "造訪次數", + "format": "格式", + "maxBitRate": "最大位元率", + "updatedAt": "更新於", + "createdAt": "建立於" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "遺失檔案 |||| 遺失檔案", + "empty": "無遺失檔案", + "fields": { + "path": "路徑", + "size": "檔案大小", + "libraryName": "媒體庫", + "updatedAt": "遺失於" + }, + "actions": { + "remove": "刪除", + "remove_all": "刪除所有" + }, + "notifications": { + "removed": "遺失檔案已刪除" + } + }, + "library": { + "name": "媒體庫 |||| 媒體庫", + "fields": { + "name": "名稱", + "path": "路徑", + "remotePath": "遠端路徑", + "lastScanAt": "上次掃描", + "songCount": "歌曲", + "albumCount": "專輯", + "artistCount": "藝人", + "totalSongs": "歌曲", + "totalAlbums": "專輯", + "totalArtists": "藝人", + "totalFolders": "資料夾", + "totalFiles": "檔案", + "totalMissingFiles": "遺失檔案", + "totalSize": "總大小", + "totalDuration": "時長", + "defaultNewUsers": "新使用者預設媒體庫", + "createdAt": "建立於", + "updatedAt": "更新於" + }, + "sections": { + "basic": "基本資訊", + "statistics": "統計" + }, + "actions": { + "scan": "掃描媒體庫", + "manageUsers": "管理使用者權限", + "viewDetails": "查看詳細資料" + }, + "notifications": { + "created": "成功建立媒體庫", + "updated": "成功更新媒體庫", + "deleted": "成功刪除媒體庫", + "scanStarted": "開始掃描媒體庫", + "scanCompleted": "媒體庫掃描完成" + }, + "validation": { + "nameRequired": "請輸入媒體庫名稱", + "pathRequired": "請提供媒體庫路徑", + "pathNotDirectory": "媒體庫路徑必須為目錄", + "pathNotFound": "媒體庫路徑不存在", + "pathNotAccessible": "無法存取媒體庫路徑", + "pathInvalid": "媒體庫路徑無效" + }, + "messages": { + "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。", + "scanInProgress": "正在掃描...", + "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫" + } } + }, + "ra": { + "auth": { + "welcome1": "感謝您安裝 Navidrome!", + "welcome2": "開始前,請先建立一個管理員帳號", + "confirmPassword": "確認密碼", + "buttonCreateAdmin": "建立管理員", + "auth_check_error": "請登入以繼續", + "user_menu": "個人檔案", + "username": "使用者名稱", + "password": "密碼", + "sign_in": "登入", + "sign_in_error": "驗證失敗,請重試", + "logout": "登出", + "insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。" + }, + "validation": { + "invalidChars": "請使用字母和數字", + "passwordDoesNotMatch": "密碼不相符", + "required": "必填", + "minLength": "必須不少於 %{min} 個字元", + "maxLength": "必須不多於 %{max} 個字元", + "minValue": "必須不小於 %{min}", + "maxValue": "必須不大於 %{max}", + "number": "必須為數字", + "email": "必須為有效的電子郵件", + "oneOf": "必須為以下其中一項:%{options}", + "regex": "必須符合指定的格式(正規表達式):%{pattern}", + "unique": "必須是唯一的", + "url": "必須為有效的網址" + }, + "action": { + "add_filter": "加入篩選", + "add": "加入", + "back": "返回", + "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "取消", + "clear_input_value": "清除", + "clone": "複製", + "confirm": "確認", + "create": "建立", + "delete": "刪除", + "edit": "編輯", + "export": "匯出", + "list": "列表", + "refresh": "重新整理", + "remove_filter": "清除此條件", + "remove": "移除", + "save": "儲存", + "search": "搜尋", + "show": "顯示", + "sort": "排序", + "undo": "復原", + "expand": "展開", + "close": "關閉", + "open_menu": "開啟選單", + "close_menu": "關閉選單", + "unselect": "取消選取", + "skip": "略過", + "share": "分享", + "download": "下載" + }, + "boolean": { + "true": "是", + "false": "否" + }, + "page": { + "create": "建立 %{name}", + "dashboard": "儀表板", + "edit": "%{name} #%{id}", + "error": "發生錯誤", + "list": "%{name}", + "loading": "載入中", + "not_found": "找不到", + "show": "%{name} #%{id}", + "empty": "還沒有 %{name}。", + "invite": "您要建立一個嗎?" + }, + "input": { + "file": { + "upload_several": "拖曳多個檔案上傳或點擊選擇一個", + "upload_single": "拖曳單個檔案上傳或點擊選擇一個" + }, + "image": { + "upload_several": "拖曳多個圖片上傳或點擊選擇一個", + "upload_single": "拖曳單個圖片上傳或點擊選擇一個" + }, + "references": { + "all_missing": "未找到參考數據", + "many_missing": "至少有一條參考數據不再可用", + "single_missing": "關聯的參考數據不再可用" + }, + "password": { + "toggle_visible": "隱藏密碼", + "toggle_hidden": "顯示密碼" + } + }, + "message": { + "about": "關於", + "are_you_sure": "您確定嗎?", + "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除這 %{smart_count} 個項目嗎?", + "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}", + "delete_content": "您確定要刪除該項目?", + "delete_title": "刪除 %{name} #%{id}", + "details": "詳細資訊", + "error": "發生客戶端錯誤,您的請求無法完成", + "invalid_form": "提交內容無效,請檢查錯誤", + "loading": "正在載入頁面,請稍候", + "no": "否", + "not_found": "您輸入了錯誤的連結或連結遺失", + "yes": "是", + "unsaved_changes": "某些更改尚未儲存,您確定要離開此頁面嗎?" + }, + "navigation": { + "no_results": "沒有找到結果", + "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁", + "page_out_of_boundaries": "頁碼 %{page} 超出邊界", + "page_out_from_end": "已經是最後一頁", + "page_out_from_begin": "已經是第一頁", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "每頁項目數:", + "next": "下一頁", + "prev": "上一頁", + "skip_nav": "跳至內容" + }, + "notification": { + "updated": "項目已更新 |||| %{smart_count} 項已更新", + "created": "項目已建立", + "deleted": "項目已刪除 |||| %{smart_count} 項已刪除", + "bad_item": "項目不正確", + "item_doesnt_exist": "項目不存在", + "http_error": "伺服器通訊錯誤", + "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊", + "i18n_error": "無法載入所選語言", + "canceled": "操作已取消", + "logged_out": "您的工作階段已結束,請重新登入", + "new_version": "發現新版本!請重新整理視窗" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "顯示欄位", + "layout": "版面", + "grid": "網格", + "table": "表格" + } + }, + "message": { + "note": "注意", + "transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。", + "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", + "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", + "noSimilarSongsFound": "找不到相似歌曲", + "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": "查看更多…", + "shareOriginalFormat": "分享原始格式", + "shareDialogTitle": "分享 %{resource} '%{name}'", + "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}", + "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", + "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}", + "shareFailure": "分享連結複製失敗:%{url}", + "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "下載原始格式" + }, + "menu": { + "library": "媒體庫", + "librarySelector": { + "allLibraries": "所有媒體庫 (%{count})", + "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", + "selectLibraries": "選取媒體庫", + "none": "無" + }, + "settings": "設定", + "version": "版本", + "theme": "主題", + "personal": { + "name": "個人化", + "options": { + "theme": "主題", + "language": "語言", + "defaultView": "預設畫面", + "desktop_notifications": "桌面通知", + "lastfmNotConfigured": "Last.fm API 金鑰未設定", + "lastfmScrobbling": "啟用 Last.fm 音樂記錄", + "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", + "replaygain": "重播增益模式", + "preAmp": "重播增益前置放大器 (dB)", + "gain": { + "none": "無", + "album": "專輯增益", + "track": "曲目增益" + } + } + }, + "albumList": "專輯", + "playlists": "播放清單", + "sharedPlaylists": "分享的播放清單", + "about": "關於" + }, + "player": { + "playListsText": "播放佇列", + "openText": "開啟", + "closeText": "關閉", + "notContentText": "沒有音樂", + "clickToPlayText": "點擊播放", + "clickToPauseText": "點擊暫停", + "nextTrackText": "下一首", + "previousTrackText": "上一首", + "reloadText": "重新載入", + "volumeText": "音量", + "toggleLyricText": "切換歌詞", + "toggleMiniModeText": "最小化", + "destroyText": "關閉", + "downloadText": "下載", + "removeAudioListsText": "清空播放佇列", + "clickToDeleteText": "點擊刪除 %{name}", + "emptyLyricText": "無歌詞", + "playModeText": { + "order": "順序播放", + "orderLoop": "循環播放", + "singleLoop": "單曲循環", + "shufflePlay": "隨機播放" + } + }, + "about": { + "links": { + "homepage": "首頁", + "source": "原始碼", + "featureRequests": "功能請求", + "lastInsightsCollection": "最近一次洞察資料收集", + "insights": { + "disabled": "已停用", + "waiting": "等待中" + } + }, + "tabs": { + "about": "關於", + "config": "設定" + }, + "config": { + "configName": "設定名稱", + "environmentVariable": "環境變數", + "currentValue": "目前值", + "configurationFile": "設定檔案", + "exportToml": "匯出設定(TOML 格式)", + "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿", + "exportFailed": "設定複製失敗", + "devFlagsHeader": "開發旗標(可能會更改/刪除)", + "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除" + } + }, + "activity": { + "title": "運作狀況", + "totalScanned": "已掃描的資料夾總數", + "quickScan": "快速掃描", + "fullScan": "完全掃描", + "serverUptime": "伺服器運作時間", + "serverDown": "伺服器已離線", + "scanType": "掃描類型", + "status": "掃描錯誤", + "elapsedTime": "經過時間" + }, + "nowPlaying": { + "title": "正在播放", + "empty": "無播放內容", + "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" + }, + "help": { + "title": "Navidrome 快捷鍵", + "hotkeys": { + "show_help": "顯示此說明", + "toggle_menu": "顯示/隱藏選單側欄", + "toggle_play": "播放/暫停", + "prev_song": "上一首歌", + "next_song": "下一首歌", + "current_song": "前往目前歌曲", + "vol_up": "提高音量", + "vol_down": "降低音量", + "toggle_love": "新增此歌曲至收藏" + } + } } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index c4278ef82..1cab8a0b7 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -42,7 +42,7 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f2.mp3": {}, "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, - "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")}, + "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, }, } job = &scanJob{ diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 60f7c3394..d9c722955 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go index f081eca78..1636e1dbb 100644 --- a/server/nativeapi/library.go +++ b/server/nativeapi/library.go @@ -13,11 +13,11 @@ import ( ) // User-library association endpoints (admin only) -func (n *Router) addUserLibraryRoute(r chi.Router) { +func (api *Router) addUserLibraryRoute(r chi.Router) { r.Route("/user/{id}/library", func(r chi.Router) { r.Use(parseUserIDMiddleware) - r.Get("/", getUserLibraries(n.libs)) - r.Put("/", setUserLibraries(n.libs)) + r.Get("/", getUserLibraries(api.libs)) + r.Put("/", setUserLibraries(api.libs)) }) } diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 4e6d34582..950338492 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -30,7 +30,7 @@ var _ = Describe("Library API", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 0d311f492..2b455e622 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -8,9 +8,9 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" ) @@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string { return "missing_files" } -func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - ids, _ := p.Strings("id") - err := ds.WithTx(func(tx model.DataStore) error { +func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + p := req.Params(r) + ids, _ := p.Strings("id") + + var err error if len(ids) == 0 { - _, err := tx.MediaFile(ctx).DeleteAllMissing() - return err - } - return tx.MediaFile(ctx).DeleteMissing(ids) - }) - if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Missing file not found", "id", ids[0]) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - err = ds.GC(ctx) - if err != nil { - log.Error(ctx, "Error running GC after deleting missing tracks", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Refresh artist stats in background after deleting missing files - go func() { - bgCtx := request.AddValues(context.Background(), r.Context()) - if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil { - log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + err = maintenance.DeleteAllMissingFiles(ctx) } else { - log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") + err = maintenance.DeleteMissingFiles(ctx, ids) } - }() - writeDeleteManyResponse(w, r, ids) + if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Missing file not found", "id", ids[0]) + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "failed to delete missing files", http.StatusInternalServerError) + return + } + + writeDeleteManyResponse(w, r, ids) + } } var _ model.ResourceRepository = &missingRepository{} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 370bdbd1e..969650e0a 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -22,70 +22,71 @@ import ( type Router struct { http.Handler - ds model.DataStore - share core.Share - playlists core.Playlists - insights metrics.Insights - libs core.Library + ds model.DataStore + share core.Share + playlists core.Playlists + insights metrics.Insights + libs core.Library + maintenance core.Maintenance } -func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService} +func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance} r.Handler = r.routes() return r } -func (n *Router) routes() http.Handler { +func (api *Router) routes() http.Handler { r := chi.NewRouter() // Public - n.RX(r, "/translation", newTranslationRepository, false) + api.RX(r, "/translation", newTranslationRepository, false) // Protected r.Group(func(r chi.Router) { - r.Use(server.Authenticator(n.ds)) + r.Use(server.Authenticator(api.ds)) r.Use(server.JWTRefresher) - r.Use(server.UpdateLastAccessMiddleware(n.ds)) - n.R(r, "/user", model.User{}, true) - n.R(r, "/song", model.MediaFile{}, false) - n.R(r, "/album", model.Album{}, false) - n.R(r, "/artist", model.Artist{}, false) - n.R(r, "/genre", model.Genre{}, false) - n.R(r, "/player", model.Player{}, true) - n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) - n.R(r, "/radio", model.Radio{}, true) - n.R(r, "/tag", model.Tag{}, true) + r.Use(server.UpdateLastAccessMiddleware(api.ds)) + api.R(r, "/user", model.User{}, true) + api.R(r, "/song", model.MediaFile{}, false) + api.R(r, "/album", model.Album{}, false) + api.R(r, "/artist", model.Artist{}, false) + 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.R(r, "/tag", model.Tag{}, true) if conf.Server.EnableSharing { - n.RX(r, "/share", n.share.NewRepository, true) + api.RX(r, "/share", api.share.NewRepository, true) } - n.addPlaylistRoute(r) - n.addPlaylistTrackRoute(r) - n.addSongPlaylistsRoute(r) - n.addQueueRoute(r) - n.addMissingFilesRoute(r) - n.addKeepAliveRoute(r) - n.addInsightsRoute(r) + api.addPlaylistRoute(r) + api.addPlaylistTrackRoute(r) + api.addSongPlaylistsRoute(r) + api.addQueueRoute(r) + api.addMissingFilesRoute(r) + api.addKeepAliveRoute(r) + api.addInsightsRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { - n.addInspectRoute(r) - n.addConfigRoute(r) - n.addUserLibraryRoute(r) - n.RX(r, "/library", n.libs.NewRepository, true) + api.addInspectRoute(r) + api.addConfigRoute(r) + api.addUserLibraryRoute(r) + api.RX(r, "/library", api.libs.NewRepository, true) }) }) return r } -func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { +func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { constructor := func(ctx context.Context) rest.Repository { - return n.ds.Resource(ctx, model) + return api.ds.Resource(ctx, model) } - n.RX(r, pathPrefix, constructor, persistable) + api.RX(r, pathPrefix, constructor, persistable) } -func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { +func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { r.Route(pathPrefix, func(r chi.Router) { r.Get("/", rest.GetAll(constructor)) if persistable { @@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository }) } -func (n *Router) addPlaylistRoute(r chi.Router) { +func (api *Router) addPlaylistRoute(r chi.Router) { constructor := func(ctx context.Context) rest.Repository { - return n.ds.Resource(ctx, model.Playlist{}) + return api.ds.Resource(ctx, model.Playlist{}) } r.Route("/playlist", func(r chi.Router) { @@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) { rest.Post(constructor)(w, r) return } - createPlaylistFromM3U(n.playlists)(w, r) + createPlaylistFromM3U(api.playlists)(w, r) }) r.Route("/{id}", func(r chi.Router) { @@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) { }) } -func (n *Router) addPlaylistTrackRoute(r chi.Router) { +func (api *Router) addPlaylistTrackRoute(r chi.Router) { r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylist(n.ds)(w, r) + getPlaylist(api.ds)(w, r) }) r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(n.ds)(w, r) + deleteFromPlaylist(api.ds)(w, r) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { - addToPlaylist(n.ds)(w, r) + addToPlaylist(api.ds)(w, r) }) }) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylistTrack(n.ds)(w, r) + getPlaylistTrack(api.ds)(w, r) }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { - reorderItem(n.ds)(w, r) + reorderItem(api.ds)(w, r) }) r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(n.ds)(w, r) + deleteFromPlaylist(api.ds)(w, r) }) }) }) } -func (n *Router) addSongPlaylistsRoute(r chi.Router) { +func (api *Router) addSongPlaylistsRoute(r chi.Router) { r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) { - getSongPlaylists(n.ds)(w, r) + getSongPlaylists(api.ds)(w, r) }) } -func (n *Router) addQueueRoute(r chi.Router) { +func (api *Router) addQueueRoute(r chi.Router) { r.Route("/queue", func(r chi.Router) { - r.Get("/", getQueue(n.ds)) - r.Post("/", saveQueue(n.ds)) - r.Put("/", updateQueue(n.ds)) - r.Delete("/", clearQueue(n.ds)) + r.Get("/", getQueue(api.ds)) + r.Post("/", saveQueue(api.ds)) + r.Put("/", updateQueue(api.ds)) + r.Delete("/", clearQueue(api.ds)) }) } -func (n *Router) addMissingFilesRoute(r chi.Router) { +func (api *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { - n.RX(r, "/", newMissingRepository(n.ds), false) - r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteMissingFiles(n.ds, w, r) - }) + api.RX(r, "/", newMissingRepository(api.ds), false) + r.Delete("/", deleteMissingFiles(api.maintenance)) }) } @@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin } } -func (n *Router) addInspectRoute(r chi.Router) { +func (api *Router) addInspectRoute(r chi.Router) { if conf.Server.Inspect.Enabled { r.Group(func(r chi.Router) { if conf.Server.Inspect.MaxRequests > 0 { @@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) { conf.Server.Inspect.BacklogTimeout) r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout))) } - r.Get("/inspect", inspect(n.ds)) + r.Get("/inspect", inspect(api.ds)) }) } } -func (n *Router) addConfigRoute(r chi.Router) { +func (api *Router) addConfigRoute(r chi.Router) { if conf.Server.DevUIShowConfig { r.Get("/config/*", getConfig) } } -func (n *Router) addKeepAliveRoute(r chi.Router) { +func (api *Router) addKeepAliveRoute(r chi.Router) { r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) }) } -func (n *Router) addInsightsRoute(r chi.Router) { +func (api *Router) addInsightsRoute(r chi.Router) { r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { - last, success := n.insights.LastRun(r.Context()) + last, success := api.insights.LastRun(r.Context()) if conf.Server.EnableInsightsCollector { _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) } else { diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index d7209a164..b52042643 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index bb3d20e5c..d08d3eb5b 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler { h(r, "createBookmark", api.CreateBookmark) h(r, "deleteBookmark", api.DeleteBookmark) h(r, "getPlayQueue", api.GetPlayQueue) + h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex) h(r, "savePlayQueue", api.SavePlayQueue) + h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index d7286c20c..b1e71b1c7 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { Current: currentID, Position: pq.Position, Username: user.UserName, - Changed: &pq.UpdatedAt, + Changed: pq.UpdatedAt, ChangedBy: pq.ChangedBy, } return response, nil @@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { } return newResponse(), nil } + +func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { + user, _ := request.UserFrom(r.Context()) + + repo := api.ds.PlayQueue(r.Context()) + pq, err := repo.RetrieveWithMediaFiles(user.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + if pq == nil || len(pq.Items) == 0 { + return newResponse(), nil + } + + response := newResponse() + + var index *int + if len(pq.Items) > 0 { + index = &pq.Current + } + + response.PlayQueueByIndex = &responses.PlayQueueByIndex{ + Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile), + CurrentIndex: index, + Position: pq.Position, + Username: user.UserName, + Changed: pq.UpdatedAt, + ChangedBy: pq.ChangedBy, + } + return response, nil +} + +func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + ids, _ := p.Strings("id") + + position := p.Int64Or("position", 0) + + var err error + var currentIndex int + + if len(ids) > 0 { + currentIndex, err = p.Int("currentIndex") + if err != nil || currentIndex < 0 || currentIndex >= len(ids) { + return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err) + } + } + + items := slice.Map(ids, func(id string) model.MediaFile { + return model.MediaFile{ID: id} + }) + + user, _ := request.UserFrom(r.Context()) + client, _ := request.ClientFrom(r.Context()) + + pq := &model.PlayQueue{ + UserID: user.ID, + Current: currentIndex, + Position: position, + ChangedBy: client, + Items: items, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + } + + repo := api.ds.PlayQueue(r.Context()) + err = repo.Store(pq) + if err != nil { + return nil, err + } + return newResponse(), nil +} diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 17ce3c2b0..a364651c5 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, + {Name: "indexBasedQueue", Versions: []int32{1}}, } return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3cc680afe..58dca682c 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(3), + HaveLen(4), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON index 88eebb276..70b10c059 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON @@ -6,6 +6,7 @@ "openSubsonic": true, "playQueue": { "username": "", + "changed": "0001-01-01T00:00:00Z", "changedBy": "" } } diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML index 5af3d9157..597781cbd 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON new file mode 100644 index 000000000..efc032ca6 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON @@ -0,0 +1,22 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "playQueueByIndex": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "title", + "isVideo": false + } + ], + "currentIndex": 0, + "position": 243, + "username": "user1", + "changed": "0001-01-01T00:00:00Z", + "changedBy": "a_client" + } +} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML new file mode 100644 index 000000000..1d31b334e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON new file mode 100644 index 000000000..ad49a35e5 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON @@ -0,0 +1,12 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "playQueueByIndex": { + "username": "", + "changed": "0001-01-01T00:00:00Z", + "changedBy": "" + } +} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML new file mode 100644 index 000000000..d99681f4c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index ffda2aa43..0724d2fff 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -60,6 +60,7 @@ type Subsonic struct { // OpenSubsonic extensions OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` + PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` } const ( @@ -439,12 +440,21 @@ type TopSongs struct { } type PlayQueue struct { - Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` - Current string `xml:"current,attr,omitempty" json:"current,omitempty"` - Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` - Username string `xml:"username,attr" json:"username"` - Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"` - ChangedBy string `xml:"changedBy,attr" json:"changedBy"` + Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` + Current string `xml:"current,attr,omitempty" json:"current,omitempty"` + Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` + Username string `xml:"username,attr" json:"username"` + Changed time.Time `xml:"changed,attr" json:"changed"` + ChangedBy string `xml:"changedBy,attr" json:"changedBy"` +} + +type PlayQueueByIndex struct { + Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` + CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"` + Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` + Username string `xml:"username,attr" json:"username"` + Changed time.Time `xml:"changed,attr" json:"changed"` + ChangedBy string `xml:"changedBy,attr" json:"changedBy"` } type Bookmark struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 7238665cf..2ee8e080d 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -768,7 +768,7 @@ var _ = Describe("Responses", func() { response.PlayQueue.Username = "user1" response.PlayQueue.Current = "111" response.PlayQueue.Position = 243 - response.PlayQueue.Changed = &time.Time{} + response.PlayQueue.Changed = time.Time{} response.PlayQueue.ChangedBy = "a_client" child := make([]Child, 1) child[0] = Child{Id: "1", Title: "title", IsDir: false} @@ -783,6 +783,40 @@ var _ = Describe("Responses", func() { }) }) + Describe("PlayQueueByIndex", func() { + BeforeEach(func() { + response.PlayQueueByIndex = &PlayQueueByIndex{} + }) + + Context("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.PlayQueueByIndex.Username = "user1" + response.PlayQueueByIndex.CurrentIndex = gg.P(0) + response.PlayQueueByIndex.Position = 243 + response.PlayQueueByIndex.Changed = time.Time{} + response.PlayQueueByIndex.ChangedBy = "a_client" + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.PlayQueueByIndex.Entry = child + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + Describe("Shares", func() { BeforeEach(func() { response.Shares = &Shares{} diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc new file mode 100644 index 000000000..223c37de0 --- /dev/null +++ b/tests/fixtures/bom-test.lrc @@ -0,0 +1,4 @@ +[00:00.00] 作曲 : 柏大輔 +NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0. +This tests BOM handling in lyrics parsing (GitHub issue #4631). +The BOM bytes are: 0xEF 0xBB 0xBF \ No newline at end of file diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc new file mode 100644 index 000000000..e40ea3255 Binary files /dev/null and b/tests/fixtures/bom-utf16-test.lrc differ diff --git a/tests/fixtures/playlists/bom-test-utf16.m3u b/tests/fixtures/playlists/bom-test-utf16.m3u new file mode 100644 index 000000000..9c2e9d599 Binary files /dev/null and b/tests/fixtures/playlists/bom-test-utf16.m3u differ diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u new file mode 100644 index 000000000..f5a00806c --- /dev/null +++ b/tests/fixtures/playlists/bom-test.m3u @@ -0,0 +1,6 @@ +#EXTM3U +# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning +# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing. +#PLAYLIST:Test Playlist +#EXTINF:123,Test Artist - Test Song +test.mp3 diff --git a/tests/test_helpers.go b/tests/test_helpers.go index 1251c90cd..0a2cad4ad 100644 --- a/tests/test_helpers.go +++ b/tests/test_helpers.go @@ -6,7 +6,10 @@ import ( "path/filepath" "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/id" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" ) type testingT interface { @@ -35,3 +38,23 @@ func ClearDB() error { `) return err } + +// LogHook sets up a logrus test hook and configures the default logger to use it. +// It returns the hook and a cleanup function to restore the default logger. +// Example usage: +// +// hook, cleanup := LogHook() +// defer cleanup() +// // ... perform logging operations ... +// Expect(hook.LastEntry()).ToNot(BeNil()) +// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) +// Expect(hook.LastEntry().Message).To(Equal("log message")) +func LogHook() (*test.Hook, func()) { + l, hook := test.NewNullLogger() + log.SetLevel(log.LevelWarn) + log.SetDefaultLogger(l) + return hook, func() { + // Restore default logger after test + log.SetDefaultLogger(logrus.New()) + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 9e449c5e0..c0901a73d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", "clsx": "^2.1.1", @@ -37,8 +37,8 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.3.0", - "uuid": "^11.1.0", + "redux-saga": "^1.4.2", + "uuid": "^13.0.0", "workbox-cli": "^7.3.0" }, "devDependencies": { @@ -46,46 +46,35 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.15.21", - "@types/react": "^17.0.86", + "@types/node": "^24.9.1", + "@types/react": "^17.0.89", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.5.0", - "@vitest/coverage-v8": "^3.1.4", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.3", "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.5", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "happy-dom": "^17.4.7", + "eslint-plugin-react-refresh": "^0.4.24", + "happy-dom": "^20.0.8", "jsdom": "^26.1.0", - "prettier": "^3.5.3", + "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^6.3.5", - "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.1.4" + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", - "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", - "dev": true - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", @@ -128,20 +117,20 @@ } }, "node_modules/@babel/core": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", - "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helpers": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -165,14 +154,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -299,6 +288,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -324,13 +321,13 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -411,9 +408,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "engines": { "node": ">=6.9.0" } @@ -440,23 +437,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1464,9 +1461,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -1497,29 +1495,29 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2100,10 +2098,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2173,10 +2172,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2214,76 +2214,6 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "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==", - "dev": true, - "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" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?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==", - "dev": true, - "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.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/types": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", @@ -2301,16 +2231,21 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2321,14 +2256,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", @@ -2339,14 +2266,14 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2596,16 +2523,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -2630,16 +2547,17 @@ } }, "node_modules/@redux-saga/core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.3.0.tgz", - "integrity": "sha512-L+i+qIGuyWn7CIg7k1MteHGfttKPmxwZR5E7OsGikCL2LzYA0RERlaUY00Y3P3ZV2EYgrsYlBrGs6cJP5OKKqA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", + "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.6.3", - "@redux-saga/deferred": "^1.2.1", - "@redux-saga/delay-p": "^1.2.1", - "@redux-saga/is": "^1.1.3", - "@redux-saga/symbols": "^1.1.3", - "@redux-saga/types": "^1.2.1", + "@babel/runtime": "^7.28.4", + "@redux-saga/deferred": "^1.3.1", + "@redux-saga/delay-p": "^1.3.1", + "@redux-saga/is": "^1.2.1", + "@redux-saga/symbols": "^1.2.1", + "@redux-saga/types": "^1.3.1", "typescript-tuple": "^2.2.1" }, "funding": { @@ -2648,41 +2566,46 @@ } }, "node_modules/@redux-saga/deferred": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.2.1.tgz", - "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz", + "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==", + "license": "MIT" }, "node_modules/@redux-saga/delay-p": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.2.1.tgz", - "integrity": "sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", + "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", + "license": "MIT", "dependencies": { - "@redux-saga/symbols": "^1.1.3" + "@redux-saga/symbols": "^1.2.1" } }, "node_modules/@redux-saga/is": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.3.tgz", - "integrity": "sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", + "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", + "license": "MIT", "dependencies": { - "@redux-saga/symbols": "^1.1.3", - "@redux-saga/types": "^1.2.1" + "@redux-saga/symbols": "^1.2.1", + "@redux-saga/types": "^1.3.1" } }, "node_modules/@redux-saga/symbols": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.3.tgz", - "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", + "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", + "license": "MIT" }, "node_modules/@redux-saga/types": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz", - "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", + "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", + "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", "dev": true }, "node_modules/@rollup/plugin-node-resolve": { @@ -2793,6 +2716,12 @@ "node": ">=6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -2844,17 +2773,17 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", - "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", + "picocolors": "^1.1.1", "redent": "^3.0.0" }, "engines": { @@ -2863,24 +2792,12 @@ "yarn": ">=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@testing-library/react": { "version": "12.1.5", @@ -3017,6 +2934,22 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3067,12 +3000,13 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/normalize-package-data": { @@ -3086,9 +3020,10 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "17.0.86", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.86.tgz", - "integrity": "sha512-lPFuSjA85jecet6D4ZsPvCFuSrz6g2hkTSUw8MM0x5z2EndPV/itGnYQ39abjxd7F+cAcxLGtKQjnLn9cNUz3g==", + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3158,6 +3093,13 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", @@ -3370,50 +3312,49 @@ "dev": true }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", - "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", "dev": true, "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz", - "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz", + "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "debug": "^4.4.0", + "@vitest/utils": "4.0.3", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", + "istanbul-reports": "^3.2.0", "magicast": "^0.3.5", "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.4", - "vitest": "3.1.4" + "@vitest/browser": "4.0.3", + "vitest": "4.0.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3422,36 +3363,38 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", - "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz", + "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==", "dev": true, "dependencies": { - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", - "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz", + "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==", "dev": true, "dependencies": { - "@vitest/spy": "3.1.4", + "@vitest/spy": "4.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.19" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -3463,24 +3406,24 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", - "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz", + "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==", "dev": true, "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", - "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz", + "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==", "dev": true, "dependencies": { - "@vitest/utils": "3.1.4", + "@vitest/utils": "4.0.3", "pathe": "^2.0.3" }, "funding": { @@ -3488,13 +3431,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", - "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz", + "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.1.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.0.3", + "magic-string": "^0.30.19", "pathe": "^2.0.3" }, "funding": { @@ -3502,26 +3445,22 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", - "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz", + "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==", "dev": true, - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", - "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz", + "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.1.4", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.0.3", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3813,6 +3752,23 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4027,9 +3983,10 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -4104,15 +4061,6 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", @@ -4262,19 +4210,12 @@ ] }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -4297,15 +4238,6 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4590,7 +4522,8 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cssstyle": { "version": "4.3.1", @@ -4692,9 +4625,9 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, @@ -4763,15 +4696,6 @@ "node": ">=4" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -5014,12 +4938,6 @@ "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==" }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -5390,9 +5308,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -5444,10 +5362,11 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5510,19 +5429,21 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", "dev": true, + "license": "MIT", "peerDependencies": { "eslint": ">=8.40" } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5590,10 +5511,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5716,9 +5638,9 @@ "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "engines": { "node": ">=12.0.0" @@ -5964,34 +5886,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -6172,9 +6066,10 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6292,18 +6187,37 @@ "dev": true }, "node_modules/happy-dom": { - "version": "17.4.7", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.7.tgz", - "integrity": "sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==", + "version": "20.0.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz", + "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==", "dev": true, + "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", + "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -7186,9 +7100,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -7215,21 +7129,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -7248,9 +7147,10 @@ } }, "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7663,12 +7563,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true - }, "node_modules/lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -7695,12 +7589,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -7865,15 +7759,6 @@ "node": ">= 6" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8263,12 +8148,6 @@ "node": ">=8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, "node_modules/package-json/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8348,28 +8227,6 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", @@ -8393,15 +8250,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8432,9 +8280,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -8451,7 +8299,7 @@ } ], "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8477,9 +8325,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -9258,9 +9106,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "engines": { "node": ">=0.10.0" @@ -9461,11 +9309,12 @@ } }, "node_modules/redux-saga": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.3.0.tgz", - "integrity": "sha512-J9RvCeAZXSTAibFY0kGw6Iy4EdyDNW7k6Q+liwX+bsck7QVsU78zz8vpBRweEfANxnnlG/xGGeOvf6r8UXzNJQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", + "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", + "license": "MIT", "dependencies": { - "@redux-saga/core": "^1.3.0" + "@redux-saga/core": "^1.4.2" } }, "node_modules/reflect.getprototypeof": { @@ -10184,9 +10033,9 @@ "dev": true }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, "node_modules/stop-iteration-iterator": { @@ -10231,27 +10080,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==", - "dev": true, - "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==", - "dev": true - }, "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", @@ -10384,19 +10212,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==", - "dev": true, - "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", @@ -10509,55 +10324,6 @@ "node": ">=10" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10592,13 +10358,13 @@ "dev": true }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -10608,10 +10374,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -10622,9 +10391,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "engines": { "node": ">=12" @@ -10633,28 +10402,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "engines": { "node": ">=14.0.0" @@ -10875,6 +10626,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", + "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" } @@ -10882,12 +10634,14 @@ "node_modules/typescript-logic": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", + "license": "MIT" }, "node_modules/typescript-tuple": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", + "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" } @@ -10910,10 +10664,11 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -11072,15 +10827,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validate-npm-package-license": { @@ -11098,23 +10854,23 @@ "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -11123,14 +10879,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -11171,32 +10927,10 @@ } } }, - "node_modules/vite-node": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", - "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-pwa": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", - "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz", + "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==", "dev": true, "dependencies": { "debug": "^4.3.6", @@ -11212,8 +10946,8 @@ "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@vite-pwa/assets-generator": "^0.2.6", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "workbox-build": "^7.3.0", "workbox-window": "^7.3.0" }, @@ -11224,10 +10958,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -11238,9 +10975,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "engines": { "node": ">=12" @@ -11250,38 +10987,37 @@ } }, "node_modules/vitest": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", - "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz", + "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", "dev": true, "dependencies": { - "@vitest/expect": "3.1.4", - "@vitest/mocker": "3.1.4", - "@vitest/pretty-format": "^3.1.4", - "@vitest/runner": "3.1.4", - "@vitest/snapshot": "3.1.4", - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.0.3", + "@vitest/mocker": "4.0.3", + "@vitest/pretty-format": "4.0.3", + "@vitest/runner": "4.0.3", + "@vitest/snapshot": "4.0.3", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", "pathe": "^2.0.3", + "picomatch": "^4.0.3", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.13", - "tinypool": "^1.0.2", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.4", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -11289,9 +11025,11 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.4", - "@vitest/ui": "3.1.4", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.3", + "@vitest/browser-preview": "4.0.3", + "@vitest/browser-webdriverio": "4.0.3", + "@vitest/ui": "4.0.3", "happy-dom": "*", "jsdom": "*" }, @@ -11305,7 +11043,13 @@ "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -11319,6 +11063,18 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -11868,97 +11624,6 @@ "workbox-core": "7.3.0" } }, - "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==", - "dev": true, - "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_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==", - "dev": true, - "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.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/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==", - "dev": true, - "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/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index b9c93316b..a3612aaf4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,7 +18,7 @@ "dependencies": { "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", "clsx": "^2.1.1", @@ -46,8 +46,8 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.3.0", - "uuid": "^11.1.0", + "redux-saga": "^1.4.2", + "uuid": "^13.0.0", "workbox-cli": "^7.3.0" }, "devDependencies": { @@ -55,27 +55,27 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.15.21", - "@types/react": "^17.0.86", + "@types/node": "^24.9.1", + "@types/react": "^17.0.89", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.5.0", - "@vitest/coverage-v8": "^3.1.4", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.3", "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.5", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "happy-dom": "^17.4.7", + "eslint-plugin-react-refresh": "^0.4.24", + "happy-dom": "^20.0.8", "jsdom": "^26.1.0", - "prettier": "^3.5.3", + "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^6.3.5", - "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.1.4" + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" }, "overrides": { "vite": { diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 40b927a89..f10f8dbd3 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -42,6 +42,9 @@ const useStyles = makeStyles({ }, }) +const formatReleaseType = (record) => + record?.tagValue ? humanize(record?.tagValue) : '-- None --' + const AlbumFilter = (props) => { const classes = useStyles() const translate = useTranslate() @@ -142,9 +145,7 @@ const AlbumFilter = (props) => { > - record?.tagValue ? humanize(record?.tagValue) : '-- None --' - } + optionText={formatReleaseType} /> diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 05ca6ddf7..03419add3 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -127,6 +127,7 @@ const Player = () => { /> ), locale: locale(translate), + sortableOptions: { delay: 200, delayOnTouchOnly: true }, }), [gainInfo, isDesktop, playerTheme, translate, playerState.mode], ) diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.jsx index cef50b228..cd19ffa03 100644 --- a/ui/src/common/Linkify.test.jsx +++ b/ui/src/common/Linkify.test.jsx @@ -1,6 +1,5 @@ import React from 'react' import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' import Linkify from './Linkify' const URL = 'http://www.example.com' diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js index 5bd0dd0be..27f53c872 100644 --- a/ui/src/eventStream.test.js +++ b/ui/src/eventStream.test.js @@ -25,7 +25,7 @@ describe('startEventStream', () => { beforeEach(() => { dispatch = vi.fn() - global.EventSource = vi.fn((url) => { + global.EventSource = vi.fn().mockImplementation(function (url) { instance = new MockEventSource(url) return instance }) diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index b576e7713..b1a2e4c90 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -40,6 +40,11 @@ export default { color: '#ebdbb2', }, }, + MuiIconButton: { + root: { + color: '#ebdbb2', + }, + }, MuiChip: { clickable: { background: '#49483e', diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js index 824cf7e67..97dda93ab 100644 --- a/ui/src/themes/ligera.js +++ b/ui/src/themes/ligera.js @@ -70,7 +70,7 @@ export default { }, background: { default: '#f0f2f5', - paper: 'inherit', + paper: bLight['500'], }, text: { secondary: '#232323', @@ -450,13 +450,21 @@ export default { }, RaPaginationActions: { button: { - backgroundColor: 'inherit', + backgroundColor: '#fff', + color: '#000', minWidth: 48, margin: '0 4px', - border: '1px solid #282828', + border: '1px solid #cccccc', '@global': { '> .MuiButton-label': { padding: 0, + color: '#656565', + '&:hover': { + color: '#fff !important', + }, + }, + '> .MuiButton-label > svg': { + color: '#656565', }, }, }, diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index 74cce6e15..cfcb84b05 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => { return new Date(date).toLocaleDateString(locale, options) } -export const formatNumber = (value) => { +export const formatNumber = (value, locale) => { if (value === null || value === undefined) return '0' - return value.toLocaleString() + return value.toLocaleString(locale) } diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index 7709dd91b..d633e96f2 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -121,35 +121,35 @@ describe('formatDuration2', () => { describe('formatNumber', () => { it('handles null and undefined values', () => { - expect(formatNumber(null)).toEqual('0') - expect(formatNumber(undefined)).toEqual('0') + expect(formatNumber(null, 'en-CA')).toEqual('0') + expect(formatNumber(undefined, 'en-CA')).toEqual('0') }) it('formats integers', () => { - expect(formatNumber(0)).toEqual('0') - expect(formatNumber(1)).toEqual('1') - expect(formatNumber(123)).toEqual('123') - expect(formatNumber(1000)).toEqual('1,000') - expect(formatNumber(1234567)).toEqual('1,234,567') + expect(formatNumber(0, 'en-CA')).toEqual('0') + expect(formatNumber(1, 'en-CA')).toEqual('1') + expect(formatNumber(123, 'en-CA')).toEqual('123') + expect(formatNumber(1000, 'en-CA')).toEqual('1,000') + expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567') }) it('formats decimal numbers', () => { - expect(formatNumber(123.45)).toEqual('123.45') - expect(formatNumber(1234.567)).toEqual('1,234.567') + expect(formatNumber(123.45, 'en-CA')).toEqual('123.45') + expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567') }) it('formats negative numbers', () => { - expect(formatNumber(-123)).toEqual('-123') - expect(formatNumber(-1234)).toEqual('-1,234') - expect(formatNumber(-123.45)).toEqual('-123.45') + expect(formatNumber(-123, 'en-CA')).toEqual('-123') + expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234') + expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45') }) }) describe('formatFullDate', () => { it('format dates', () => { - expect(formatFullDate('2011', 'en-US')).toEqual('2011') - expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011') - expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985') + expect(formatFullDate('2011', 'en-CA')).toEqual('2011') + expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011') + expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985') expect(formatFullDate('199704')).toEqual('') }) }) diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go new file mode 100644 index 000000000..89d3997f3 --- /dev/null +++ b/utils/ioutils/ioutils.go @@ -0,0 +1,33 @@ +package ioutils + +import ( + "io" + "os" + + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly. +// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8. +// This is particularly useful for reading user-provided text files (like LRC lyrics, +// playlists) that may have been created on Windows, which often adds BOM markers. +// +// Reference: https://en.wikipedia.org/wiki/Byte_order_mark +func UTF8Reader(r io.Reader) io.Reader { + return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder())) +} + +// UTF8ReadFile reads the named file and returns its contents as a byte slice, +// automatically handling BOM markers. It's similar to os.ReadFile but strips +// UTF-8 BOM and converts UTF-16 encoded files to UTF-8. +func UTF8ReadFile(filename string) ([]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + reader := UTF8Reader(file) + return io.ReadAll(reader) +} diff --git a/utils/ioutils/ioutils_test.go b/utils/ioutils/ioutils_test.go new file mode 100644 index 000000000..7f5483879 --- /dev/null +++ b/utils/ioutils/ioutils_test.go @@ -0,0 +1,117 @@ +package ioutils + +import ( + "bytes" + "io" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIOUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "IO Utils Suite") +} + +var _ = Describe("UTF8Reader", func() { + Context("when reading text with UTF-8 BOM", func() { + It("strips the UTF-8 BOM marker", func() { + // UTF-8 BOM is EF BB BF + input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hello")) + }) + + It("strips UTF-8 BOM from multi-line text", func() { + // Test with the actual LRC file format + input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("[00:00.00] test")) + }) + }) + + Context("when reading text without BOM", func() { + It("passes through unchanged", func() { + input := []byte("hello world") + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hello world")) + }) + }) + + Context("when reading UTF-16 LE encoded text", func() { + It("converts to UTF-8 and strips BOM", func() { + // UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE + input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hi")) + }) + }) + + Context("when reading UTF-16 BE encoded text", func() { + It("converts to UTF-8 and strips BOM", func() { + // UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE + input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hi")) + }) + }) + + Context("when reading empty content", func() { + It("returns empty string", func() { + reader := UTF8Reader(bytes.NewReader([]byte{})) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("")) + }) + }) +}) + +var _ = Describe("UTF8ReadFile", func() { + Context("when reading a file with UTF-8 BOM", func() { + It("strips the BOM marker", func() { + // Use the actual fixture from issue #4631 + contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc") + Expect(err).ToNot(HaveOccurred()) + + // Should NOT start with BOM + Expect(contents[0]).ToNot(Equal(byte(0xEF))) + // Should start with '[' + Expect(contents[0]).To(Equal(byte('['))) + Expect(string(contents)).To(HavePrefix("[00:00.00]")) + }) + }) + + Context("when reading a file without BOM", func() { + It("reads the file normally", func() { + contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc") + Expect(err).ToNot(HaveOccurred()) + + // Should contain the expected content + Expect(string(contents)).To(ContainSubstring("We're no strangers to love")) + }) + }) + + Context("when reading a non-existent file", func() { + It("returns an error", func() { + _, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc") + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/utils/str/str.go b/utils/str/str.go index 8a94488de..f662473da 100644 --- a/utils/str/str.go +++ b/utils/str/str.go @@ -2,6 +2,7 @@ package str import ( "strings" + "unicode/utf8" ) var utf8ToAscii = func() *strings.Replacer { @@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string { } return list[0] } + +// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated. +// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual +// string content will be truncated to fit within the maxRunes limit including the suffix. +func TruncateRunes(s string, maxRunes int, suffix string) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + + suffixRunes := utf8.RuneCountInString(suffix) + truncateAt := maxRunes - suffixRunes + if truncateAt < 0 { + truncateAt = 0 + } + + runes := []rune(s) + if truncateAt >= len(runes) { + return s + suffix + } + + return string(runes[:truncateAt]) + suffix +} diff --git a/utils/str/str_test.go b/utils/str/str_test.go index 0c3524e4e..511805831 100644 --- a/utils/str/str_test.go +++ b/utils/str/str_test.go @@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() { Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album")) }) }) + + Describe("TruncateRunes", func() { + It("returns string unchanged if under max runes", func() { + Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello")) + }) + + It("returns string unchanged if exactly at max runes", func() { + Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello")) + }) + + It("truncates and adds suffix when over max runes", func() { + Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello...")) + }) + + It("handles unicode characters correctly", func() { + // 6 emoji characters, maxRunes=5, suffix="..." (3 runes) + // So content gets 5-3=2 runes + Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁...")) + }) + + It("handles multi-byte UTF-8 characters", func() { + // Characters like é are single runes + Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca...")) + }) + + It("works with empty suffix", func() { + Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello")) + }) + + It("accounts for suffix length in truncation", func() { + // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content + result := str.TruncateRunes("hello world this is long", 10, "...") + Expect(result).To(Equal("hello w...")) + // Verify total rune count is <= maxRunes + runeCount := len([]rune(result)) + Expect(runeCount).To(BeNumerically("<=", 10)) + }) + + It("handles very long suffix gracefully", func() { + // If suffix is longer than maxRunes, we still add it + // but the content will be truncated to 0 + result := str.TruncateRunes("hello world", 5, "... (truncated)") + // Result will be just the suffix (since truncateAt=0) + Expect(result).To(Equal("... (truncated)")) + }) + + It("handles empty string", func() { + Expect(str.TruncateRunes("", 10, "...")).To(Equal("")) + }) + + It("uses custom suffix", func() { + // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes + // "hello world" is 11 runes exactly, so we need a longer string + Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]")) + }) + + DescribeTable("truncates at rune boundaries (not byte boundaries)", + func(input string, maxRunes int, suffix string, expected string) { + Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected)) + }, + Entry("ASCII", "abcdefghij", 5, "...", "ab..."), + Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."), + Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"), + Entry("Japanese", "こんにちは世界", 3, "…", "こん…"), + ) + }) }) var testPaths = []string{