Compare commits

...

25 Commits

Author SHA1 Message Date
Rob Emery
131c0c565c feat(insights): detecting packaging method (#3841)
* Adding environmental variable so that navidrome can detect
if its running as an MSI install for insights

* Renaming to be ND_PACKAGE_TYPE so we can reuse this for the
.deb/.rpm stats as well

* Packaged implies a bool, this is a description so it should
be packaging or just package imo

* wixl currently doesn't support <Environment> so I'm swapping out
to a file next-door to the configuration file, we should be
able to reuse this for deb/rpm as well

* Using a file we should be able to add support for linux like this
also

* MSI should copy the package into place for us, it's not a KeyPath
as older versions won't have it, so it's presence doesn't indicate
the installed status of the package

* OK this doesn't exist, need to find another way to do it

* package to .package and moving to the datadir

* fix(scanner): better log message when AutoImportPlaylists is disabled

Fix #3861

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): support ID3v2 embedded images in WAV files

Fix #3867

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): show bitDepth in song info dialog

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): don't break if the ND_CONFIGFILE does not exist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(docker): automatically loads a navidrome.toml file from /data, if available

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(server): custom ArtistJoiner config (#3873)

* feat(server): custom ArtistJoiner config

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): organize ArtistLinkField, add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): use display artist

* feat(ui): use display artist

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: remove some BFR-related TODOs that are not valid anymore

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: remove more outdated TODOs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): elapsed time for folder processing is wrong in the logs

Signed-off-by: Deluan <deluan@navidrome.org>

* Should be able to reuse this mechanism with deb and rpm, I think
it would be nice to know which specific one it is without guessing
based on /etc/debian_version or something; but it doesn't look like
that is exposed by goreleaser into an env or anything :/

* Need to reference the installed file and I think Id's don't require []

* Need to add into the root directory for this to work

* That was not deliberately removed

* feat: add RPM and DEB package configuration files for Navidrome

Signed-off-by: Deluan <deluan@navidrome.org>

* Don't need this as goreleaser will sort it out

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2025-11-09 12:57:55 -05:00
Kendall Garner
53ff33866d feat(subsonic): implement indexBasedQueue extension (#4244)
* redo this whole PR, but clearner now that better errata is in

* update play queue types
2025-11-09 12:52:05 -05:00
Deluan
508670ecfb Revert "feat(ui): add Vietnamese localization for the application"
This reverts commit 9621a40f29.
2025-11-09 12:41:25 -05:00
Deluan
c369224597 test: fix flaky CacheWarmer deduplication test
Fixed race condition in the 'deduplicates items in buffer' test where the
background worker goroutine could process and clear the buffer before the
test could verify its contents. Added fc.SetReady(false) to keep the cache
unavailable during the test, ensuring buffered items remain in memory for
verification. This matches the pattern already used in the 'adds multiple
items to buffer' test.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-09 12:19:28 -05:00
Deluan
ff583970f0 chore(deps): update golang.org/x/sync to v0.18.0 and golang.org/x/sys to v0.38.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:05:29 -05:00
Deluan
38ca65726a chore(deps): update wazero to version 1.10.0 and clean up go.mod
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:04:20 -05:00
Deluan Quintão
5ce6e16d96 fix: album statistics not updating after deleting missing files (#4668)
* feat: add album refresh functionality after deleting missing files

Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets.

Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: extract missing files deletion into reusable service layer

Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability.

The MissingFiles service handles:
- Deletion of specific or all missing files via transaction
- Garbage collection after deletion
- Extraction of affected album IDs from missing files
- Background refresh of artist and album statistics

The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go.

Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency.

* refactor: consolidate maintenance operations into unified service

Consolidate MissingFiles and RefreshAlbums functionality into a new Maintenance service. This refactoring:
- Creates core.Maintenance interface combining DeleteMissingFiles, DeleteAllMissingFiles, and RefreshAlbums methods
- Moves RefreshAlbums logic from AlbumRepository persistence layer to core Maintenance service
- Removes MissingFiles interface and moves its implementation to maintenanceService
- Updates all references in wire providers, native API router, and handlers
- Removes RefreshAlbums interface method from AlbumRepository model
- Improves separation of concerns by centralizing maintenance operations in the core domain

This change provides a cleaner API and better organization of maintenance-related database operations.

* refactor: remove MissingFiles interface and update references

Remove obsolete MissingFiles interface and its references:
- Delete core/missing_files.go and core/missing_files_test.go
- Remove RefreshAlbums method from AlbumRepository interface and implementation
- Remove RefreshAlbums tests from AlbumRepository test suite
- Update wire providers to use NewMaintenance instead of NewMissingFiles
- Update native API router to use Maintenance service
- Update missing.go handler to use Maintenance interface

All functionality is now consolidated in the core.Maintenance service.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename RefreshAlbums to refreshAlbums and update related calls

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize album refresh logic and improve test coverage

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: simplify logging setup in tests with reusable LogHook function

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add synchronization to logger and maintenance service for thread safety

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 20:11:00 -05:00
Deluan Quintão
69527085db fix(ui): resolve transparent dropdown background in Ligera theme (#4665)
Fixed the multi-library selector dropdown background in the Ligera theme by changing the palette.background.paper value from 'inherit' to bLight['500'] ('#ffffff'). This ensures the dropdown has a solid white background that properly overlays content, making the library selection options clearly readable.

Closes #4502
2025-11-08 12:47:02 -05:00
Nagi
9bb933c0d6 fix(ui): fix Playlist Italian translation(#4642)
In Italian, we usually use "Playlist" rather than "Scalette/a". "Scalette/a" refers to other functions or objects.
2025-11-07 18:41:23 -05:00
Deluan Quintão
6f4fa76772 fix(ui): update Galician, Dutch, Thai translations from POEditor (#4416)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-11-07 18:20:39 -05:00
Deluan
9621a40f29 feat(ui): add Vietnamese localization for the application 2025-11-07 18:13:46 -05:00
DDinghoya
df95dffa74 fix(ui): update ko.json (#4443)
* Update ko.json

* Update ko.json

Removed remove one of the entrie as below

"shuffleAll": "모두 셔플"

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json
2025-11-07 18:10:38 -05:00
York
a59b59192a fix(ui): update zh-Hant.json (#4454)
* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.
2025-11-07 18:06:41 -05:00
Deluan Quintão
4f7dc105b0 fix(ui): correct track ordering when sorting playlists by album (#4657)
* fix(deps): update wazero dependencies to resolve issues

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: correct track ordering when sorting playlists by album

Fixed issue #3177 where tracks within multi-disc albums were displayed out of order when sorting playlists by album. The playlist track repository was using an incomplete sort mapping that only sorted by album name and artist, missing the critical disc_number and track_number fields.

Changed the album sort mapping in playlist_track_repository from:
  order_album_name, order_album_artist_name
to:
  order_album_name, order_album_artist_name, disc_number, track_number, order_artist_name, title

This now matches the sorting used in the media file repository, ensuring tracks are sorted by:
1. Album name (groups by album)
2. Album artist (handles compilations)
3. Disc number (multi-disc album discs in order)
4. Track number (tracks within disc in order)
5. Artist name and title (edge cases with missing metadata)

Added comprehensive tests with a multi-disc test album to verify correct sorting behavior.

* chore: sync go.mod and go.sum with master

* chore: align playlist album sort order with mediafile_repository (use album_id)

* fix: clean up test playlist to prevent state leakage in randomized test runs

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 16:50:54 -05:00
Deluan Quintão
e918e049e2 fix: update wazero dependency to resolve ARM64 SIGILL crash (#4655)
* fix(deps): update wazero dependencies to resolve issues

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version for issue resolution

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 15:07:09 -05:00
Deluan Quintão
1e8d28ff46 fix: qualify user id filter to avoid ambiguous column (#4511) 2025-11-06 14:54:01 -05:00
Kendall Garner
a128b3cf98 fix(db): make playqueue position field an integer (#4481) 2025-11-06 14:41:09 -05:00
Deluan Quintão
290a9fdeaa test: fix locale-dependent tests by making formatNumber locale-aware (#4619)
- Add optional locale parameter to formatNumber function
- Update tests to explicitly pass 'en-US' locale for deterministic results
- Maintains backward compatibility: defaults to system locale when no locale specified
- No need for cross-env or environment variable manipulation
- Tests now pass consistently regardless of system locale

Related to #4417
2025-11-06 14:34:00 -05:00
Deluan
58b5ed86df refactor: extract TruncateRunes function for safe string truncation with suffix
Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	core/share.go
#	core/share_test.go
2025-11-06 14:27:38 -05:00
beerpsi
fe1cee0159 fix(share): slice content label by utf-8 runes (#4634)
* fix(share): slice content label by utf-8 runes

* Apply suggestions about avoiding allocations

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* lint: remove unused import

* test: add test cases for CJK truncation

* test: add tests for ASCII labels too

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-06 14:24:07 -05:00
Deluan
3dfaa8cca1 ci: go mod tidy
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:53:41 -05:00
Deluan
0a5abfc1b1 chore: update actions/upload-artifact and actions/download-artifact to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:43:35 -05:00
Deluan
c501bc6996 chore(deps): update ginkgo to version 2.27.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:41:16 -05:00
Deluan
0c71842b12 chore: update Go version to 1.25.4
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:40:44 -05:00
pca006132
e86dc03619 fix(ui): allow scrolling in play queue by adding delay (#4562) 2025-11-01 20:47:03 -04:00
57 changed files with 2356 additions and 690 deletions

View File

@@ -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 }}
@@ -267,7 +267,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download digests
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: /tmp/digests
pattern: digests-*
@@ -320,7 +320,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v5
- 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
@@ -357,7 +357,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v5
- 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@v5
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 }}

View File

@@ -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
}

View File

@@ -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"))

226
core/maintenance.go Normal file
View File

@@ -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()
}

382
core/maintenance_test.go Normal file
View File

@@ -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)
}

View File

@@ -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

View File

@@ -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"`

View File

@@ -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

View File

@@ -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() {

View File

@@ -18,6 +18,7 @@ var Set = wire.NewSet(
NewShare,
NewPlaylists,
NewLibrary,
NewMaintenance,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -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

13
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/navidrome/navidrome
go 1.25.3
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 (
@@ -43,7 +43,7 @@ require (
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.27.1
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
@@ -56,15 +56,15 @@ require (
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.9.0
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-20251023183803-a4bb9ffd2546
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/sync v0.17.0
golang.org/x/sys v0.37.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
@@ -124,7 +124,6 @@ require (
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
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect

20
go.sum
View File

@@ -186,8 +186,8 @@ 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.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
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=
@@ -201,8 +201,6 @@ 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.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=
@@ -267,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
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=
@@ -286,8 +284,6 @@ 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=
@@ -336,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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -354,8 +350,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=

View File

@@ -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
}

View File

@@ -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,
}))
})

View File

@@ -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() {

View File

@@ -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,
}
)

View File

@@ -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
})
})
})

View File

@@ -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",

View File

@@ -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),
})

View File

@@ -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}"))
})
})
})

View File

@@ -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"

View File

@@ -0,0 +1 @@
deb

View File

@@ -0,0 +1 @@
rpm

View File

@@ -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

View File

@@ -69,6 +69,12 @@
</Directory>
</Directory>
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
</Component>
</Directory>
</Directory>
<InstallUISequence>
@@ -81,6 +87,7 @@
<ComponentRef Id='Configuration'/>
<ComponentRef Id='MainExecutable' />
<ComponentRef Id='FFMpegExecutable' />
<ComponentRef Id='PackageFile' />
</Feature>
</Product>
</Wix>

View File

@@ -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"
}
}

View File

@@ -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": ""
}
}
}
}

View File

@@ -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 단축키",

View File

@@ -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"
}
}

View File

@@ -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} นาทีที่แล้ว"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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))
})
}

View File

@@ -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

View File

@@ -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{}

View File

@@ -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 {

View File

@@ -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()
})

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}}),
))
})
})

View File

@@ -6,6 +6,7 @@
"openSubsonic": true,
"playQueue": {
"username": "",
"changed": "0001-01-01T00:00:00Z",
"changedBy": ""
}
}

View File

@@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playQueue username="" changedBy=""></playQueue>
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
</subsonic-response>

View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
</playQueueByIndex>
</subsonic-response>

View File

@@ -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": ""
}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
</subsonic-response>

View File

@@ -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 {

View File

@@ -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{}

View File

@@ -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())
}
}

View File

@@ -127,6 +127,7 @@ const Player = () => {
/>
),
locale: locale(translate),
sortableOptions: { delay: 200, delayOnTouchOnly: true },
}),
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
)

View File

@@ -70,7 +70,7 @@ export default {
},
background: {
default: '#f0f2f5',
paper: 'inherit',
paper: bLight['500'],
},
text: {
secondary: '#232323',

View File

@@ -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)
}

View File

@@ -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('')
})
})

View File

@@ -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
}

View File

@@ -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{