From 1de84dbd0cad4607fd5662ad49c140d7d1fb89ba Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 12 Jul 2025 16:49:00 -0400 Subject: [PATCH 01/64] refactor(ui): replace translation key with direct character for remove action Signed-off-by: Deluan --- resources/i18n/de.json | 3 +-- resources/i18n/el.json | 3 +-- resources/i18n/eu.json | 3 +-- resources/i18n/fr.json | 3 +-- resources/i18n/hu.json | 3 +-- resources/i18n/id.json | 3 +-- resources/i18n/pt-br.json | 3 +-- resources/i18n/ru.json | 3 +-- resources/i18n/sv.json | 3 +-- resources/i18n/tr.json | 3 +-- ui/src/dialogs/SelectPlaylistInput.jsx | 2 +- ui/src/dialogs/SelectPlaylistInput.test.jsx | 8 ++------ ui/src/i18n/en.json | 3 +-- 13 files changed, 14 insertions(+), 29 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 090360c81..89e14f296 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -207,8 +207,7 @@ "saveQueue": "Warteschlange in Wiedergabeliste speichern", "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen", - "removeSymbol": "×" + "removeFromSelection": "Von Auswahl entfernen" }, "message": { "duplicate_song": "Duplikate hinzufügen", diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 0e8b5a9e5..d588fa080 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -207,8 +207,7 @@ "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή", - "removeSymbol": "x" + "removeFromSelection": "Αφαίρεση από την επιλογή" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 1adb8b8ba..cb5927a74 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -207,8 +207,7 @@ "makePrivate": "Egin pribatua", "searchOrCreate": "Bilatu erreprodukzio-zerrenda edo idatzi berria sortzeko…", "pressEnterToCreate": "Sakatu Enter erreprodukzio-zerrenda berria sortzeko", - "removeFromSelection": "Kendu hautaketatik", - "removeSymbol": "×" + "removeFromSelection": "Kendu hautaketatik" }, "message": { "duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan", diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 69250ef18..35bf8987c 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -207,8 +207,7 @@ "saveQueue": "Sauvegarder la file de lecture dans la playlist", "searchOrCreate": "Chercher ou créer une nouvelle playlist...", "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection", - "removeSymbol": "×" + "removeFromSelection": "Supprimer de la sélection" }, "message": { "duplicate_song": "Ajouter les titres déjà présents dans la playlist", diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index 23a3cc6b5..184f78fe4 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -207,8 +207,7 @@ "makePrivate": "Priváttá tétel", "searchOrCreate": "Keress lejátszási listák között vagy hozz létre egyet...", "pressEnterToCreate": "Nyomj Entert, hogy létrehozz egy lejátszási listát", - "removeFromSelection": "Eltávolítás a kiválasztásból", - "removeSymbol": "×" + "removeFromSelection": "Eltávolítás a kiválasztásból" }, "message": { "duplicate_song": "Duplikált számok hozzáadása", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index f97d3366b..c4bcb72eb 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -207,8 +207,7 @@ "saveQueue": "Simpan Antrean ke Playlist", "searchOrCreate": "Cari playlist atau ketik untuk buat baru..", "pressEnterToCreate": "Tekan Enter untuk membuat playlist baru", - "removeFromSelection": "Hapus yang dipilih", - "removeSymbol": "×" + "removeFromSelection": "Hapus yang dipilih" }, "message": { "duplicate_song": "Tambahkan lagu duplikat", diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 3fa5b16eb..454de39db 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -207,8 +207,7 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção", - "removeSymbol": "×" + "removeFromSelection": "Remover da seleção" }, "message": { "duplicate_song": "Adicionar músicas duplicadas", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 54378acd3..a2b275693 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -207,8 +207,7 @@ "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", - "removeFromSelection": "Удалить из списка выделенных", - "removeSymbol": "×" + "removeFromSelection": "Удалить из списка выделенных" }, "message": { "duplicate_song": "Повторяющиеся треки", diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index d4b289515..915a56121 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -207,8 +207,7 @@ "saveQueue": "Spara kö till spellista", "searchOrCreate": "Sök spellista eller skapa ny...", "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval", - "removeSymbol": "×" + "removeFromSelection": "Ta bort från urval" }, "message": { "duplicate_song": "Lägg till dubletter", diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index c75451d41..d412189ff 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -207,8 +207,7 @@ "saveQueue": "Kuyruktakileri Çalma Listesine Kaydet", "searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...", "pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın", - "removeFromSelection": "Seçimden kaldır", - "removeSymbol": "×" + "removeFromSelection": "Seçimden kaldır" }, "message": { "duplicate_song": "Yinelenen şarkıları ekle", diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 0e0636924..d401dd822 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -226,7 +226,7 @@ const SelectedPlaylistChip = ({ playlist, onRemove }) => { onClick={() => onRemove(playlist)} title={translate('resources.playlist.actions.removeFromSelection')} > - {translate('resources.playlist.actions.removeSymbol')} + {'×'} ) diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 93a14b325..4ffcdf0b6 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -205,9 +205,7 @@ describe('SelectPlaylistInput', () => { }) // Find and click the remove button (translation key) - const removeButton = screen.getByText( - 'resources.playlist.actions.removeSymbol', - ) + const removeButton = screen.getByText('×') fireEvent.click(removeButton) await waitFor(() => { @@ -480,9 +478,7 @@ describe('SelectPlaylistInput', () => { }) // Remove the first selected playlist via chip - const removeButtons = screen.getAllByText( - 'resources.playlist.actions.removeSymbol', - ) + const removeButtons = screen.getAllByText('×') fireEvent.click(removeButtons[0]) await waitFor(() => { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 7bd124ec6..6b647d213 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -207,8 +207,7 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection", - "removeSymbol": "×" + "removeFromSelection": "Remove from selection" }, "message": { "duplicate_song": "Add duplicated songs", From 5b73a4d5b72ae6ff17b9efd0e29886c93e39f1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 13 Jul 2025 15:23:58 -0300 Subject: [PATCH 02/64] feat(plugins): add TimeNow function to SchedulerService (#4337) * feat: add TimeNow function to SchedulerService plugin Added new TimeNow RPC method to the SchedulerService host service that returns the current time in two formats: RFC3339Nano string and Unix milliseconds int64. This provides plugins with a standardized way to get current time information from the host system. The implementation includes: - TimeNowRequest/TimeNowResponse protobuf message definitions - Go host service implementation using time.Now() - Complete test coverage with format validation - Generated WASM interface code for plugin communication * feat: add LocalTimeZone field to TimeNow response Added LocalTimeZone field to TimeNowResponse message in the SchedulerService plugin host service. This field contains the server's local timezone name (e.g., 'America/New_York', 'UTC') providing plugins with timezone context alongside the existing RFC3339Nano and Unix milliseconds timestamps. The implementation includes: - New local_time_zone protobuf field definition - Go implementation using time.Now().Location().String() - Updated test coverage with timezone validation - Generated protobuf serialization/deserialization code * docs: update plugin README with TimeNow function documentation Updated the plugins README.md to document the new TimeNow function in the SchedulerService. The documentation includes detailed descriptions of the three return formats (RFC3339Nano, UnixMilli, LocalTimeZone), practical use cases, and a comprehensive Go code example showing how plugins can access current time information for logging, calculations, and timezone-aware operations. * docs: remove wrong comment from InitRequest Signed-off-by: Deluan * fix: add missing TimeNow method to namedSchedulerService Added TimeNow method implementation to namedSchedulerService struct to satisfy the scheduler.SchedulerService interface contract. This method was recently added to the interface but the namedSchedulerService wrapper was not updated, causing compilation failures in plugin tests. The implementation is a simple pass-through to the underlying scheduler service since TimeNow doesn't require any special handling for named callbacks. --------- Signed-off-by: Deluan --- plugins/README.md | 43 ++- plugins/api/api.pb.go | 1 - plugins/api/api.proto | 1 - plugins/api/api_plugin_dev_named_registry.go | 4 + plugins/host/scheduler/scheduler.pb.go | 47 +++ plugins/host/scheduler/scheduler.proto | 13 + plugins/host/scheduler/scheduler_host.pb.go | 34 ++ plugins/host/scheduler/scheduler_plugin.pb.go | 23 ++ .../host/scheduler/scheduler_vtproto.pb.go | 301 ++++++++++++++++++ plugins/host_scheduler.go | 15 + plugins/host_scheduler_test.go | 25 ++ 11 files changed, 503 insertions(+), 4 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index 31f967879..100230cbf 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -196,7 +196,7 @@ See the [cache.proto](host/cache/cache.proto) file for the full API definition. #### SchedulerService -The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. +The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. ```protobuf service SchedulerService { @@ -208,11 +208,50 @@ service SchedulerService { // Cancel any scheduled job rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); } ``` +**Key Features:** + - **One-time scheduling**: Schedule a callback to be executed once after a specified delay. - **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression. +- **Current time access**: Get the current time in standardized formats for time-based operations. + +**TimeNow Function:** + +The `TimeNow` function returns the current time in three formats: + +```protobuf +message TimeNowResponse { + string rfc3339_nano = 1; // RFC3339 format with nanosecond precision + int64 unix_milli = 2; // Unix timestamp in milliseconds + string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York") +} +``` + +This allows plugins to: + +- Get high-precision timestamps for logging and event correlation +- Perform time-based calculations using Unix timestamps +- Handle timezone-aware operations by knowing the server's local timezone + +Example usage: + +```go +// Get current time information +timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{}) +if err != nil { + return err +} + +// Use the different time formats +timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z" +unixMs := timeResp.UnixMilli // 1705312245123 +timezone := timeResp.LocalTimeZone // "UTC" +``` Plugins using this service must implement the `SchedulerCallback` interface: @@ -433,7 +472,7 @@ If no permissions are needed, use an empty permissions object: `"permissions": { The following permission keys correspond to host services: | Permission | Host Service | Description | Required Fields | -|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------| +| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- | | `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` | | `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` | | `cache` | CacheService | Store and retrieve cached data with TTL | `reason` | diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go index 473598904..b570d5c61 100644 --- a/plugins/api/api.pb.go +++ b/plugins/api/api.pb.go @@ -903,7 +903,6 @@ type InitRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Empty for now Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin } diff --git a/plugins/api/api.proto b/plugins/api/api.proto index c451a82fc..7929ff9e6 100644 --- a/plugins/api/api.proto +++ b/plugins/api/api.proto @@ -194,7 +194,6 @@ service LifecycleManagement { } message InitRequest { - // Empty for now map config = 1; // Configuration specific to this plugin } diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go index 05421ad73..2ddb68779 100644 --- a/plugins/api/api_plugin_dev_named_registry.go +++ b/plugins/api/api_plugin_dev_named_registry.go @@ -88,3 +88,7 @@ func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *sch request.ScheduleId = key return n.svc.CancelSchedule(ctx, request) } + +func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return n.svc.TimeNow(ctx, request) +} diff --git a/plugins/host/scheduler/scheduler.pb.go b/plugins/host/scheduler/scheduler.pb.go index 6d4c29205..07d250cc5 100644 --- a/plugins/host/scheduler/scheduler.pb.go +++ b/plugins/host/scheduler/scheduler.pb.go @@ -154,6 +154,51 @@ func (x *CancelResponse) GetError() string { return "" } +type TimeNowRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *TimeNowRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type TimeNowResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format + UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp + LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC") +} + +func (x *TimeNowResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *TimeNowResponse) GetRfc3339Nano() string { + if x != nil { + return x.Rfc3339Nano + } + return "" +} + +func (x *TimeNowResponse) GetUnixMilli() int64 { + if x != nil { + return x.UnixMilli + } + return 0 +} + +func (x *TimeNowResponse) GetLocalTimeZone() string { + if x != nil { + return x.LocalTimeZone + } + return "" +} + // go:plugin type=host version=1 type SchedulerService interface { // One-time event scheduling @@ -162,4 +207,6 @@ type SchedulerService interface { ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error) // Cancel any scheduled job CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error) + // Get current time in multiple formats + TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error) } diff --git a/plugins/host/scheduler/scheduler.proto b/plugins/host/scheduler/scheduler.proto index 39fd32a58..d164b4f90 100644 --- a/plugins/host/scheduler/scheduler.proto +++ b/plugins/host/scheduler/scheduler.proto @@ -14,6 +14,9 @@ service SchedulerService { // Cancel any scheduled job rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); } message ScheduleOneTimeRequest { @@ -39,4 +42,14 @@ message CancelRequest { message CancelResponse { bool success = 1; // Whether cancellation was successful string error = 2; // Error message if cancellation failed +} + +message TimeNowRequest { + // Empty request - no parameters needed +} + +message TimeNowResponse { + string rfc3339_nano = 1; // Current time in RFC3339Nano format + int64 unix_milli = 2; // Current time as Unix milliseconds timestamp + string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC") } \ No newline at end of file diff --git a/plugins/host/scheduler/scheduler_host.pb.go b/plugins/host/scheduler/scheduler_host.pb.go index 289f3f0bb..714603a3b 100644 --- a/plugins/host/scheduler/scheduler_host.pb.go +++ b/plugins/host/scheduler/scheduler_host.pb.go @@ -44,6 +44,11 @@ func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerS WithParameterNames("offset", "size"). Export("cancel_schedule") + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("time_now") + _, err := envBuilder.Instantiate(ctx) return err } @@ -134,3 +139,32 @@ func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, st ptrLen := (ptr << uint64(32)) | uint64(len(buf)) stack[0] = ptrLen } + +// Get current time in multiple formats + +func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(TimeNowRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.TimeNow(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/scheduler/scheduler_plugin.pb.go b/plugins/host/scheduler/scheduler_plugin.pb.go index afbed2bf0..ab7f8cd48 100644 --- a/plugins/host/scheduler/scheduler_plugin.pb.go +++ b/plugins/host/scheduler/scheduler_plugin.pb.go @@ -88,3 +88,26 @@ func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelReq } return response, nil } + +//go:wasmimport env time_now +func _time_now(ptr uint32, size uint32) uint64 + +func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _time_now(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(TimeNowResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/scheduler/scheduler_vtproto.pb.go b/plugins/host/scheduler/scheduler_vtproto.pb.go index 1606ab7f0..ee6421783 100644 --- a/plugins/host/scheduler/scheduler_vtproto.pb.go +++ b/plugins/host/scheduler/scheduler_vtproto.pb.go @@ -256,6 +256,91 @@ func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.LocalTimeZone) > 0 { + i -= len(m.LocalTimeZone) + copy(dAtA[i:], m.LocalTimeZone) + i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone))) + i-- + dAtA[i] = 0x1a + } + if m.UnixMilli != 0 { + i = encodeVarint(dAtA, i, uint64(m.UnixMilli)) + i-- + dAtA[i] = 0x10 + } + if len(m.Rfc3339Nano) > 0 { + i -= len(m.Rfc3339Nano) + copy(dAtA[i:], m.Rfc3339Nano) + i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func encodeVarint(dAtA []byte, offset int, v uint64) int { offset -= sov(v) base := offset @@ -355,6 +440,37 @@ func (m *CancelResponse) SizeVT() (n int) { return n } +func (m *TimeNowRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *TimeNowResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Rfc3339Nano) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.UnixMilli != 0 { + n += 1 + sov(uint64(m.UnixMilli)) + } + l = len(m.LocalTimeZone) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + func sov(x uint64) (n int) { return (bits.Len64(x|1) + 6) / 7 } @@ -915,6 +1031,191 @@ func (m *CancelResponse) UnmarshalVT(dAtA []byte) error { } return nil } +func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Rfc3339Nano = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType) + } + m.UnixMilli = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.UnixMilli |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.LocalTimeZone = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skip(dAtA []byte) (n int, err error) { l := len(dAtA) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go index e3585990a..26c5e92f8 100644 --- a/plugins/host_scheduler.go +++ b/plugins/host_scheduler.go @@ -45,6 +45,10 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul return s.ss.cancelSchedule(ctx, s.pluginID, req) } +func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return s.ss.timeNow(ctx, req) +} + type schedulerService struct { // Map of schedule IDs to their callback info schedules map[string]*ScheduledCallback @@ -260,6 +264,17 @@ func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, re }, nil } +// timeNow returns the current time in multiple formats +func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + now := time.Now() + + return &scheduler.TimeNowResponse{ + Rfc3339Nano: now.Format(time.RFC3339Nano), + UnixMilli: now.UnixMilli(), + LocalTimeZone: now.Location().String(), + }, nil +} + // runOneTimeSchedule handles the one-time schedule execution and callback func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) { tmr := time.NewTimer(delay) diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index a905313b7..1a3efaae9 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "time" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/plugins/host/scheduler" @@ -164,4 +165,28 @@ var _ = Describe("SchedulerService", func() { Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") }) }) + + Describe("TimeNow", func() { + It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() { + now := time.Now() + req := &scheduler.TimeNowRequest{} + resp, err := ss.timeNow(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli())) + Expect(resp.LocalTimeZone).ToNot(BeEmpty()) + + // Validate RFC3339Nano format can be parsed + parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano) + Expect(parseErr).ToNot(HaveOccurred()) + + // Validate that Unix milliseconds is reasonably close to the RFC3339Nano time + expectedMillis := parsedTime.UnixMilli() + Expect(resp.UnixMilli).To(Equal(expectedMillis)) + + // Validate local timezone matches the current system timezone + expectedTimezone := now.Location().String() + Expect(resp.LocalTimeZone).To(Equal(expectedTimezone)) + }) + }) }) From d8e829ad185b3d319bc6e3708b831361c346b26c Mon Sep 17 00:00:00 2001 From: bytetigers Date: Mon, 14 Jul 2025 02:30:58 +0800 Subject: [PATCH 03/64] chore: fix function name/description in comment (#4325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fix function in comment Signed-off-by: bytetigers * Update model/metadata/persistent_ids.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Signed-off-by: bytetigers Co-authored-by: Deluan Quintão Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- model/metadata/persistent_ids.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index 0a1451cfb..95e93c2fa 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -16,7 +16,7 @@ import ( type hashFunc = func(...string) string -// getPID returns the persistent ID for a given spec, getting the referenced values from the metadata +// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata // The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes // Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. // For each field, it gets all its attributes values and concatenates them, then hashes the result. From b69a7652b943e495de0bd91881e6d3ca25865692 Mon Sep 17 00:00:00 2001 From: bytesingsong Date: Mon, 14 Jul 2025 02:31:15 +0800 Subject: [PATCH 04/64] chore: fix some typos in comment and logs (#4333) Signed-off-by: bytesingsong --- core/artwork/sources.go | 2 +- release/linux/postinstall.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 121e6c38b..4250a373b 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -188,7 +188,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() - return nil, "", fmt.Errorf("error retrieveing artwork from %s: %s", imageUrl, resp.Status) + return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) } return resp.Body, imageUrl.String(), nil } diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh index 65f1d208d..f3d9c9277 100644 --- a/release/linux/postinstall.sh +++ b/release/linux/postinstall.sh @@ -4,7 +4,7 @@ # the package manager (in particular, deb) thinks that the file exists, while it is # no longer on disk. Specifically, doing a `rm /etc/navidrome/navidrome.toml` # without something like `apt purge navidrome` will result in the system believing that -# the file still exists. In this case, during isntall it will NOT extract the configuration +# the file still exists. In this case, during install it will NOT extract the configuration # file (as to not override it). Since `navidrome service install` depends on this file existing, # we will create it with the defaults anyway. if [ ! -f /etc/navidrome/navidrome.toml ]; then From adef0ea1e768f99fd1131aeed941cde6c695e978 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 15 Jul 2025 12:54:09 -0400 Subject: [PATCH 05/64] fix(plugins): resolve race condition in plugin manager registration Fixed a race condition in the plugin manager where goroutines started during plugin registration could concurrently access shared plugin maps while the main registration loop was still running. The fix separates plugin registration from background processing by collecting all plugins first, then starting background goroutines after registration is complete. This prevents concurrent read/write access to the plugins and adapters maps that was causing data races detected by the Go race detector. The solution maintains the same functionality while ensuring thread safety during the plugin scanning and registration process. Signed-off-by: Deluan --- plugins/manager.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/plugins/manager.go b/plugins/manager.go index 0800d2744..7c735c740 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -189,13 +189,6 @@ func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manif } m.mu.Unlock() - // Start pre-compilation of WASM module in background AFTER registration - go func() { - precompilePlugin(p) - // Check if this plugin implements InitService and hasn't been initialized yet - m.initializePluginIfNeeded(p) - }() - log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink) return m.plugins[pluginID] } @@ -261,6 +254,7 @@ func (m *managerImpl) ScanPlugins() { discoveries := DiscoverPlugins(root) var validPluginNames []string + var registeredPlugins []*plugin for _, discovery := range discoveries { if discovery.Error != nil { // Handle global errors (like directory read failure) @@ -284,7 +278,20 @@ func (m *managerImpl) ScanPlugins() { validPluginNames = append(validPluginNames, discovery.ID) // Register the plugin - m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest) + plugin := m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest) + if plugin != nil { + registeredPlugins = append(registeredPlugins, plugin) + } + } + + // Start background processing for all registered plugins after registration is complete + // This avoids race conditions between registration and goroutines that might unregister plugins + for _, p := range registeredPlugins { + go func(plugin *plugin) { + precompilePlugin(plugin) + // Check if this plugin implements InitService and hasn't been initialized yet + m.initializePluginIfNeeded(plugin) + }(p) } log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames) From 3c1e5603d0b20acf81b3b25b8d4a4637310e944d Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 15 Jul 2025 19:12:25 -0400 Subject: [PATCH 06/64] fix(ui): don't show year "0" Signed-off-by: Deluan --- ui/src/album/AlbumDatesField.jsx | 6 ++ ui/src/album/AlbumDatesField.test.jsx | 112 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 ui/src/album/AlbumDatesField.test.jsx diff --git a/ui/src/album/AlbumDatesField.jsx b/ui/src/album/AlbumDatesField.jsx index e4cdeedce..ce1301380 100644 --- a/ui/src/album/AlbumDatesField.jsx +++ b/ui/src/album/AlbumDatesField.jsx @@ -10,6 +10,12 @@ export const AlbumDatesField = ({ className, ...rest }) => { const releaseYear = releaseDate?.toString().substring(0, 4) const yearRange = formatRange(record, 'originalYear') || record['maxYear']?.toString() + + // Don't show anything if the year starts with "0" + if (yearRange === '0' || releaseYear?.startsWith('0')) { + return null + } + let label = yearRange if (releaseYear !== undefined && yearRange !== releaseYear) { diff --git a/ui/src/album/AlbumDatesField.test.jsx b/ui/src/album/AlbumDatesField.test.jsx new file mode 100644 index 000000000..9bcd41567 --- /dev/null +++ b/ui/src/album/AlbumDatesField.test.jsx @@ -0,0 +1,112 @@ +import { describe, test, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { RecordContextProvider } from 'react-admin' +import { AlbumDatesField } from './AlbumDatesField' +import { formatRange } from '../common/index.js' + +// Mock the formatRange function +vi.mock('../common/index.js', () => ({ + formatRange: vi.fn(), +})) + +describe('AlbumDatesField', () => { + test('renders nothing when yearRange is "0"', () => { + const record = { + maxYear: '0', + releaseDate: '2020-01-01', + } + + vi.mocked(formatRange).mockReturnValue('0') + + const { container } = render( + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + test('renders nothing when releaseYear is "0"', () => { + const record = { + maxYear: '2020', + releaseDate: '0-01-01', + } + + vi.mocked(formatRange).mockReturnValue('2020') + + const { container } = render( + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + test('renders only yearRange when releaseYear is undefined', () => { + const record = { + maxYear: '2020', + } + + vi.mocked(formatRange).mockReturnValue('2020') + + const { container } = render( + + + , + ) + + expect(container.textContent).toBe('2020') + }) + + test('renders both years when they are different', () => { + const record = { + maxYear: '2018', + releaseDate: '2020-01-01', + } + + vi.mocked(formatRange).mockReturnValue('2018') + + const { container } = render( + + + , + ) + + expect(container.textContent).toBe('♫ 2018 · ○ 2020') + }) + + test('renders only yearRange when both years are the same', () => { + const record = { + maxYear: '2020', + releaseDate: '2020-01-01', + } + + vi.mocked(formatRange).mockReturnValue('2020') + + const { container } = render( + + + , + ) + + expect(container.textContent).toBe('2020') + }) + + test('applies className when provided', () => { + const record = { + maxYear: '2020', + } + + vi.mocked(formatRange).mockReturnValue('2020') + + const { container } = render( + + + , + ) + + expect(container.firstChild).toHaveClass('test-class') + }) +}) From 445880c0065abed0d5547f03ddb445ae8534daa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 17 Jul 2025 11:00:12 -0400 Subject: [PATCH 07/64] fix(ui): prevent disabled Show in Playlist menu item from triggering actions (#4356) * fix: prevent disabled Show in Playlist menu item from triggering actions Fixed bug where clicking on the disabled 'Show in Playlist' menu item would unintentionally trigger music playback and replace the queue. The menu item now properly prevents event propagation when disabled and takes no action. This resolves the issue where users would accidentally start playing music when clicking on the greyed-out menu option. The fix includes: - Custom onClick handler that stops event propagation for disabled state - Proper styling to maintain visual disabled state while allowing event handling - Comprehensive test coverage for the disabled behavior * style: clean up disabled menu item styling code Simplified the arrow function for disabled onClick handler and changed inline style from empty object to undefined when not needed. Also added a CSS class for disabled menu items for potential future use. These changes improve code readability and follow React best practices by using undefined instead of empty objects for conditional styles. --- ui/src/common/SongContextMenu.jsx | 25 ++++++++++++++++----- ui/src/common/SongContextMenu.test.jsx | 31 +++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index 32fbeb243..5eff495b3 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -31,6 +31,9 @@ const useStyles = makeStyles({ noWrap: { whiteSpace: 'nowrap', }, + disabledMenuItem: { + pointerEvents: 'auto', + }, }) const MoreButton = ({ record, onClick, info }) => { @@ -233,19 +236,29 @@ export const SongContextMenu = ({ open={open} onClose={handleMainMenuClose} > - {Object.keys(options).map( - (key) => + {Object.keys(options).map((key) => { + const showInPlaylistDisabled = + key === 'showInPlaylist' && !playlists.length + return ( options[key].enabled && ( e.stopPropagation() + : handleItemClick + } + disabled={showInPlaylistDisabled} + style={ + showInPlaylistDisabled ? { pointerEvents: 'auto' } : undefined + } > {options[key].label} - ), - )} + ) + ) + })} ({ vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) +const getPlaylistsMock = vi.fn() + vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { @@ -18,9 +20,7 @@ vi.mock('react-admin', async (importOriginal) => { window.location.hash = `#${url}` }, useDataProvider: () => ({ - getPlaylists: vi.fn().mockResolvedValue({ - data: [{ id: 'pl1', name: 'Pl 1' }], - }), + getPlaylists: getPlaylistsMock, inspect: vi.fn().mockResolvedValue({ data: { rawTags: {} }, }), @@ -32,6 +32,9 @@ describe('SongContextMenu', () => { beforeEach(() => { vi.clearAllMocks() window.location.hash = '' + getPlaylistsMock.mockResolvedValue({ + data: [{ id: 'pl1', name: 'Pl 1' }], + }) }) it('navigates to playlist when selected', async () => { @@ -79,4 +82,26 @@ describe('SongContextMenu', () => { expect(mockOnClick).not.toHaveBeenCalled() }) + + it('does nothing when "Show in Playlist" is disabled', async () => { + getPlaylistsMock.mockResolvedValue({ data: [] }) + const mockOnClick = vi.fn() + render( + +
+ +
+
, + ) + + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + + fireEvent.click( + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + expect(mockOnClick).not.toHaveBeenCalled() + }) }) From 089dbe9499dc70645d28f874a1273fb89ddb517c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 17 Jul 2025 12:14:05 -0400 Subject: [PATCH 08/64] refactor: remove unused CSS class in SongContextMenu Signed-off-by: Deluan --- ui/src/common/SongContextMenu.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index 5eff495b3..f8b0bba5e 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -31,9 +31,6 @@ const useStyles = makeStyles({ noWrap: { whiteSpace: 'nowrap', }, - disabledMenuItem: { - pointerEvents: 'auto', - }, }) const MoreButton = ({ record, onClick, info }) => { From 00c83af1702438b1940ae7c0d7bf4fd034cfdcfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 18 Jul 2025 18:41:12 -0400 Subject: [PATCH 09/64] feat: Multi-library support (#4181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(database): add user_library table and library access methods Signed-off-by: Deluan # Conflicts: # tests/mock_library_repo.go * feat(database): enhance user retrieval with library associations Signed-off-by: Deluan * feat(api): implement library management and user-library association endpoints Signed-off-by: Deluan * feat(api): restrict access to library and config endpoints to admin users Signed-off-by: Deluan * refactor(library): implement library management service and update API routes Signed-off-by: Deluan * feat(database): add library filtering to album, folder, and media file queries Signed-off-by: Deluan * refactor library service to use REST repository pattern and remove CRUD operations Signed-off-by: Deluan * add total_duration column to library and update user_library table Signed-off-by: Deluan * fix migration file name Signed-off-by: Deluan * feat(library): add library management features including create, edit, delete, and list functionalities - WIP Signed-off-by: Deluan * feat(library): enhance library validation and management with path checks and normalization - WIP Signed-off-by: Deluan * feat(library): improve library path validation and error handling - WIP Signed-off-by: Deluan * use utils/formatBytes Signed-off-by: Deluan * simplify DeleteLibraryButton.jsx Signed-off-by: Deluan * feat(library): enhance validation messages and error handling for library paths Signed-off-by: Deluan * lint Signed-off-by: Deluan * test(scanner): add tests for multi-library scanning and validation Signed-off-by: Deluan * test(scanner): improve handling of filesystem errors and ensure warnings are returned Signed-off-by: Deluan * feat(controller): add function to retrieve the most recent scan time across all libraries Signed-off-by: Deluan * feat(library): add additional fields and restructure LibraryEdit component for enhanced statistics display Signed-off-by: Deluan * feat(library): enhance LibraryCreate and LibraryEdit components with additional props and styling Signed-off-by: Deluan * feat(mediafile): add LibraryName field and update queries to include library name Signed-off-by: Deluan * feat(missingfiles): add library filter and display in MissingFilesList component Signed-off-by: Deluan * feat(library): implement scanner interface for triggering library scans on create/update Signed-off-by: Deluan # Conflicts: # cmd/wire_gen.go # cmd/wire_injectors.go # Conflicts: # cmd/wire_gen.go # Conflicts: # cmd/wire_gen.go # cmd/wire_injectors.go * feat(library): trigger scan after successful library deletion to clean up orphaned data Signed-off-by: Deluan * rename migration file for user library table to maintain versioning order Signed-off-by: Deluan * refactor: move scan triggering logic into a helper method for clarity Signed-off-by: Deluan * feat(library): add library path and name fields to album and mediafile models Signed-off-by: Deluan * feat(library): add/remove watchers on demand, not only when server starts Signed-off-by: Deluan * refactor(scanner): streamline library handling by using state-libraries for consistency Signed-off-by: Deluan * fix: track processed libraries by updating state with scan timestamps Signed-off-by: Deluan * prepend libraryID for track and album PIDs Signed-off-by: Deluan * feat(repository): apply library filtering in CountAll methods for albums, folders, and media files Signed-off-by: Deluan * feat(user): add library selection for user creation and editing Signed-off-by: Deluan * feat(library): implement library selection functionality with reducer and UI component Signed-off-by: Deluan # Conflicts: # .github/copilot-instructions.md # Conflicts: # .gitignore * feat(library): add tests for LibrarySelector and library selection hooks Signed-off-by: Deluan * test: add unit tests for file utility functions Signed-off-by: Deluan * feat(library): add library ID filtering for album resources Signed-off-by: Deluan * feat(library): streamline library ID filtering in repositories and update resource filtering logic Signed-off-by: Deluan * fix(repository): add table name handling in filter functions for SQL queries Signed-off-by: Deluan * feat(library): add refresh functionality on LibrarySelector close Signed-off-by: Deluan * feat(artist): add library ID filtering for artists in repository and update resource filtering logic Signed-off-by: Deluan # Conflicts: # persistence/artist_repository.go * Add library_id field support for smart playlists - Add library_id field to smart playlist criteria system - Supports Is and IsNot operators for filtering by library ID - Includes comprehensive test coverage for single values and lists - Enables creation of library-specific smart playlists * feat(subsonic): implement user-specific library access in GetMusicFolders Signed-off-by: Deluan * feat(library): enhance LibrarySelectionField to extract library IDs from record Signed-off-by: Deluan * feat(subsonic): update GetIndexes and GetArtists method to support library ID filtering Signed-off-by: Deluan * fix: ensure LibrarySelector dropdown refreshes on button close Added refresh() call when closing the dropdown via button click to maintain consistency with the ClickAwayListener behavior. This ensures the UI updates properly regardless of how the dropdown is closed, fixing an inconsistent refresh behavior between different closing methods. The fix tracks the previous open state and calls refresh() only when the dropdown was open and is being closed by the button click. * refactor: simplify getUserAccessibleLibraries function and update related tests Signed-off-by: Deluan * feat: enhance selectedMusicFolderIds function to handle valid music folder IDs and improve fallback logic Signed-off-by: Deluan * refactor: change ArtistRepository.GetIndex to accept multiple library IDs Updated the GetIndex method signature to accept a slice of library IDs instead of a single ID, enabling support for filtering artists across multiple libraries simultaneously. Changes include: - Modified ArtistRepository interface in model/artist.go - Updated implementation in persistence/artist_repository.go with improved library filtering logic - Refactored Subsonic API browsing.go to use new selectedMusicFolderIds helper - Added comprehensive test coverage for multiple library scenarios - Updated mock repository implementation for testing This change improves flexibility for multi-library operations while maintaining backward compatibility through the selectedMusicFolderIds helper function. * feat: add library access validation to selectedMusicFolderIds Enhanced the selectedMusicFolderIds function to validate musicFolderId parameters against the user's accessible libraries. Invalid library IDs (those the user doesn't have access to) are now silently filtered out, improving security by preventing users from accessing libraries they don't have permission for. Changes include: - Added validation logic to check musicFolderId parameters against user's accessible libraries - Added slices package import for efficient validation - Enhanced function documentation to clarify validation behavior - Added comprehensive test cases covering validation scenarios - Maintains backward compatibility with existing behavior * feat: implement multi-library support for GetAlbumList and GetAlbumList2 endpoints - Enhanced selectedMusicFolderIds helper to validate and filter library IDs - Added ApplyLibraryFilter function in filter/filters.go for library filtering - Updated getAlbumList to support musicFolderId parameter filtering - Added comprehensive tests for multi-library functionality - Supports single and multiple musicFolderId values - Falls back to all accessible libraries when no musicFolderId provided - Validates library access permissions for user security * feat: implement multi-library support for GetRandomSongs, GetSongsByGenre, GetStarred, and GetStarred2 - Added multi-library filtering to GetRandomSongs endpoint using musicFolderId parameter - Added multi-library filtering to GetSongsByGenre endpoint using musicFolderId parameter - Enhanced GetStarred and GetStarred2 to filter artists, albums, and songs by library - Added Options field to MockMediaFileRepo and MockArtistRepo for test compatibility - Added comprehensive Ginkgo/Gomega tests for all new multi-library functionality - All tests verify proper SQL filter generation and library access validation - Supports single/multiple musicFolderId values with fallback to all accessible libraries * refactor: optimize starred items queries with parallel execution and fix test isolation Refactored starred items functionality by extracting common logic into getStarredItems() method that executes artist, album, and media file queries in parallel for better performance. This eliminates code duplication between GetStarred and GetStarred2 methods while improving response times through concurrent database queries using run.Parallel(). Also fixed test isolation issues by adding missing auth.Init(ds) call in album lists test setup. This resolves nil pointer dereference errors in GetStarred and GetStarred2 tests when run independently. * fix: add ApplyArtistLibraryFilter to filter artists by associated music folders Signed-off-by: Deluan * feat: add library access methods to User model Signed-off-by: Deluan * feat: implement library access filtering for artist queries based on user permissions Signed-off-by: Deluan * feat: enhance artist library filtering based on user permissions and optimize library ID retrieval Signed-off-by: Deluan * fix: return error when any musicFolderId is invalid or inaccessible Changed behavior from silently filtering invalid library IDs to returning ErrorDataNotFound (code 70) when any provided musicFolderId parameter is invalid or the user doesn't have access to it. The error message includes the specific library number for better debugging. This affects album/song list endpoints (getAlbumList, getRandomSongs, getSongsByGenre, getStarred) to provide consistent error handling across all Subsonic API endpoints. Updated corresponding tests to expect errors instead of silent filtering. * feat: add musicFolderId parameter support to Search2 and Search3 endpoints Implemented musicFolderId parameter support for Subsonic API Search2 and Search3 endpoints, completing multi-library functionality across all Subsonic endpoints. Key changes: - Added musicFolderId parameter handling to Search2 and Search3 endpoints - Updated search logic to filter results by specified library or all accessible libraries when parameter not provided - Added proper error handling for invalid/inaccessible musicFolderId values - Refactored SearchableRepository interface to support library filtering with variadic QueryOptions - Updated repository implementations (Album, Artist, MediaFile) to handle library filtering in search operations - Added comprehensive test coverage with robust assertions verifying library filtering works correctly - Enhanced mock repositories to capture QueryOptions for test validation Signed-off-by: Deluan * feat: refresh LibraryList on scan end Signed-off-by: Deluan * fix: allow editing name of main library Signed-off-by: Deluan * refactor: implement SendBroadcastMessage method for event broadcasting Signed-off-by: Deluan * feat: add event broadcasting for library creation, update, and deletion Signed-off-by: Deluan * feat: add useRefreshOnEvents hook for custom refresh logic on event changes Signed-off-by: Deluan * feat: enhance library management with refresh event broadcasting Signed-off-by: Deluan * feat: replace AddUserLibrary and RemoveUserLibrary with SetUserLibraries for better library management Signed-off-by: Deluan * chore: remove commented-out genre repository code from persistence tests * feat: enhance library selection with master checkbox functionality Added a master checkbox to the SelectLibraryInput component, allowing users to select or deselect all libraries at once. This improves user experience by simplifying the selection process when multiple libraries are available. Additionally, updated translations in the en.json file to include a new message for selecting all libraries, ensuring consistency in user interface messaging. Signed-off-by: Deluan * feat: add default library assignment for new users Introduced a new column `default_new_users` in the library table to facilitate automatic assignment of default libraries to new regular users. When a new user is created, they will now be assigned to libraries marked as default, enhancing user experience by ensuring they have immediate access to essential resources. Additionally, updated the user repository logic to handle this new functionality and modified the user creation validation to reflect that library selection is optional for non-admin users. Signed-off-by: Deluan * fix: correct updated_at assignment in library repository Signed-off-by: Deluan * fix: improve cache buffering logic Refactored the cache buffering logic to ensure thread safety when checking the buffer length Signed-off-by: Deluan * fix formating Signed-off-by: Deluan * feat: implement per-library artist statistics with automatic aggregation Implemented comprehensive multi-library support for artist statistics that automatically aggregates stats from user-accessible libraries. This fundamental change moves artist statistics from global scope to per-library granularity while maintaining backward compatibility and transparent operation. Key changes include: - Migrated artist statistics from global artist.stats to per-library library_artist.stats - Added automatic library filtering and aggregation in existing Get/GetAll methods - Updated role-based filtering to work with per-library statistics storage - Enhanced statistics calculation to process and store stats per library - Implemented user permission-aware aggregation that respects library access control - Added comprehensive test coverage for library filtering and restricted user access - Created helper functions to ensure proper library associations in tests This enables users to see statistics that accurately reflect only the content from libraries they have access to, providing proper multi-tenant behavior while maintaining the existing API surface and UI functionality. Signed-off-by: Deluan * feat: add multi-library support with per-library tag statistics - WIP Signed-off-by: Deluan * refactor: genre and tag repositories. add comprehensive tests Signed-off-by: Deluan * feat: add multi-library support to tag repository system Implemented comprehensive library filtering for tag repositories to support the multi-library feature. This change ensures that users only see tags from libraries they have access to, while admin users can see all tags. Key changes: - Enhanced TagRepository.Add() method to accept libraryID parameter for proper library association - Updated baseTagRepository to implement library-aware queries with proper joins - Added library_tag table integration for per-library tag statistics - Implemented user permission-based filtering through user_library associations - Added comprehensive test coverage for library filtering scenarios - Updated UI data provider to include tag filtering by selected libraries - Modified scanner to pass library ID when adding tags during folder processing The implementation maintains backward compatibility while providing proper isolation between libraries for tag-based operations like genres and other metadata tags. * refactor: simplify artist repository library filtering Removed conditional admin logic from applyLibraryFilterToArtistQuery method and unified the library filtering approach to match the tag repository pattern. The method now always uses the same SQL join structure regardless of user role, with admin access handled automatically through user_library associations. Added artistLibraryIdFilter function to properly qualify library_id column references and prevent SQL ambiguity errors when multiple tables contain library_id columns. This ensures the filter targets library_artist.library_id specifically rather than causing ambiguous column name conflicts. * fix: resolve LibrarySelectionField validation error for non-admin users Fixed validation error 'At least one library must be selected for non-admin users' that appeared even when libraries were selected. The issue was caused by a data format mismatch between backend and frontend. The backend sends user data with libraries as an array of objects, but the LibrarySelectionField component expects libraryIds as an array of IDs. Added data transformation in the data provider's getOne method to automatically convert libraries array to libraryIds format when fetching user records. Also extracted validation logic into a separate userValidation module for better code organization and added comprehensive test coverage to prevent similar issues. * refactor: remove unused library access functions and related tests Signed-off-by: Deluan * refactor: rename search_test.go to searching_test.go for consistency Signed-off-by: Deluan * fix: add user context to scrobble buffer getParticipants call Added user context handling to scrobbleBufferRepository.Next method to resolve SQL error 'no such column: library_artist.library_id' when processing scrobble entries in multi-library environments. The artist repository now requires user context for proper library filtering, so we fetch the user and temporarily inject it into the context before calling getParticipants. This ensures background scrobbling operations work correctly with multi-library support. * feat: add cross-library move detection for scanner Implemented cross-library move detection for the scanner phase 2 to properly handle files moved between libraries. This prevents users from losing play counts, ratings, and other metadata when moving files across library boundaries. Changes include: - Added MediaFileRepository methods for two-tier matching: FindRecentFilesByMBZTrackID (primary) and FindRecentFilesByProperties (fallback) - Extended scanner phase 2 pipeline with processCrossLibraryMoves stage that processes files unmatched within their library - Implemented findCrossLibraryMatch with MusicBrainz Release Track ID priority and intrinsic properties fallback - Updated producer logic to handle missing tracks without matches, ensuring cross-library processing - Updated tests to reflect new producer behavior and cross-library functionality The implementation uses existing moveMatched function for unified move operations, automatically preserving all user data through database foreign key relationships. Cross-library moves are detected using the same Equals() and IsEquivalent() matching logic as within-library moves for consistency. Signed-off-by: Deluan * feat: add album annotation reassignment for cross-library moves Implemented album annotation reassignment functionality for the scanner's missing tracks phase. When tracks move between libraries and change album IDs, the system now properly reassigns album annotations (starred status, ratings) from the old album to the new album. This prevents loss of user annotations when tracks are moved across library boundaries. The implementation includes: - Thread-safe annotation reassignment using mutex protection - Duplicate reassignment prevention through processed album tracking - Graceful error handling that doesn't fail the entire move operation - Comprehensive test coverage for various scenarios including error conditions This enhancement ensures data integrity and user experience continuity during cross-library media file movements. * fix: address PR review comments for multi-library support Fixed several issues identified in PR review: - Removed unnecessary artist stats initialization check since the map is already initialized in PostScan() - Improved code clarity in user repository by extracting isNewUser variable to avoid checking count == 0 twice - Fixed library selection logic to properly handle initial library state and prevent overriding user selections These changes address code quality and logic issues identified during the multi-library support PR review. * feat: add automatic playlist statistics refreshing Implemented automatic playlist statistics (duration, size, song count) refreshing when tracks are modified. Added new refreshStats() method to recalculate statistics from playlist tracks, and SetTracks() method to update tracks and refresh statistics atomically. Modified all track manipulation methods (RemoveTracks, AddTracks, AddMediaFiles) to automatically refresh statistics. Updated playlist repository to use the new SetTracks method for consistent statistics handling. * refactor: rename AddTracks to AddMediaFilesByID for clarity Renamed the AddTracks method to AddMediaFilesByID throughout the codebase to better reflect its purpose of adding media files to a playlist by their IDs. This change improves code readability and makes the method name more descriptive of its actual functionality. Updated all references in playlist model, tests, core playlist logic, and Subsonic API handlers to use the new method name. * refactor: consolidate user context access in persistence layer Removed duplicate helper functions userId() and isAdmin() from sql_base_repository.go and consolidated all user context access to use loggedUser(r.ctx).ID and loggedUser(r.ctx).IsAdmin consistently across the persistence layer. This change eliminates code duplication and provides a single, consistent pattern for accessing user context information in repository methods. All functionality remains unchanged - this is purely a code cleanup refactoring. * refactor: eliminate MockLibraryService duplication using embedded struct - Replace 235-line MockLibraryService with 40-line embedded struct pattern - Enhance MockLibraryRepo with service-layer methods (192→310 lines) - Maintain full compatibility with existing tests - All 72 nativeapi specs pass with proper error handling Signed-off-by: Deluan * refactor: cleanup Signed-off-by: Deluan --------- Signed-off-by: Deluan --- .github/copilot-instructions.md | 53 - cmd/root.go | 2 +- cmd/wire_gen.go | 20 +- cmd/wire_injectors.go | 6 +- core/artwork/cache_warmer.go | 7 +- core/artwork/cache_warmer_test.go | 5 + core/library.go | 412 +++++++ core/library_test.go | 980 ++++++++++++++++ core/mock_library_service.go | 46 + core/playlists.go | 2 +- core/scrobbler/play_tracker.go | 6 +- core/scrobbler/play_tracker_test.go | 6 + core/wire_providers.go | 1 + ...0250701010108_add_multi_library_support.go | 119 ++ model/album.go | 2 + model/artist.go | 2 +- model/criteria/fields.go | 1 + model/criteria/operators_test.go | 4 + model/errors.go | 1 + model/folder.go | 2 +- model/library.go | 51 +- model/mediafile.go | 5 +- model/metadata/legacy_ids.go | 13 +- model/metadata/map_mediafile.go | 7 +- model/metadata/persistent_ids.go | 34 +- model/metadata/persistent_ids_test.go | 148 ++- model/playlist.go | 20 +- model/searchable.go | 2 +- model/tag.go | 2 +- model/user.go | 23 +- model/user_test.go | 83 ++ persistence/album_repository.go | 21 +- persistence/artist_repository.go | 191 ++- persistence/artist_repository_test.go | 1020 ++++++++++++----- persistence/folder_repository.go | 8 +- persistence/genre_repository.go | 35 +- persistence/genre_repository_test.go | 256 +++++ persistence/library_repository.go | 174 ++- persistence/library_repository_test.go | 93 ++ persistence/mediafile_repository.go | 57 +- persistence/persistence_suite_test.go | 31 +- persistence/playlist_repository.go | 9 +- persistence/playlist_repository_test.go | 4 +- persistence/playlist_track_repository.go | 6 +- persistence/scrobble_buffer_repository.go | 14 + persistence/sql_annotations.go | 16 +- persistence/sql_base_repository.go | 59 +- persistence/sql_bookmarks.go | 9 +- persistence/sql_tags.go | 106 ++ persistence/tag_library_filtering_test.go | 228 ++++ persistence/tag_repository.go | 82 +- persistence/tag_repository_test.go | 249 ++++ persistence/transcoding_repository.go | 8 +- persistence/user_repository.go | 140 ++- persistence/user_repository_test.go | 327 ++++++ scanner/controller.go | 39 +- scanner/controller_test.go | 2 - scanner/phase_1_folders.go | 10 +- scanner/phase_2_missing_tracks.go | 161 ++- scanner/phase_2_missing_tracks_test.go | 494 +++++++- scanner/scanner.go | 10 +- scanner/scanner_multilibrary_test.go | 831 ++++++++++++++ scanner/scanner_test.go | 16 +- scanner/watcher.go | 161 ++- server/events/events.go | 4 +- server/events/sse.go | 8 + server/nativeapi/config.go | 6 - server/nativeapi/config_test.go | 226 ++-- server/nativeapi/inspect.go | 6 - server/nativeapi/library.go | 101 ++ server/nativeapi/library_test.go | 424 +++++++ server/nativeapi/native_api.go | 27 +- server/nativeapi/native_api_song_test.go | 35 +- server/subsonic/album_lists.go | 114 +- server/subsonic/album_lists_test.go | 444 ++++++- server/subsonic/browsing.go | 43 +- server/subsonic/browsing_test.go | 160 +++ server/subsonic/filter/filters.go | 32 + server/subsonic/helpers.go | 39 + server/subsonic/helpers_test.go | 109 ++ server/subsonic/media_annotation_test.go | 4 + server/subsonic/playlists.go | 2 +- server/subsonic/searching.go | 39 +- server/subsonic/searching_test.go | 208 ++++ tests/mock_album_repo.go | 49 +- tests/mock_artist_repo.go | 53 +- tests/mock_data_store.go | 3 + tests/mock_library_repo.go | 280 ++++- tests/mock_mediafile_repo.go | 70 +- tests/mock_user_repo.go | 55 +- ui/src/App.jsx | 11 +- ui/src/actions/index.js | 1 + ui/src/actions/library.js | 12 + ui/src/album/AlbumInfo.jsx | 1 + ui/src/common/LibrarySelector.jsx | 221 ++++ ui/src/common/LibrarySelector.test.jsx | 517 +++++++++ ui/src/common/SelectLibraryInput.jsx | 228 ++++ ui/src/common/SelectLibraryInput.test.jsx | 458 ++++++++ ui/src/common/SongInfo.jsx | 1 + ui/src/common/index.js | 1 + ui/src/common/useLibrarySelection.js | 44 + ui/src/common/useLibrarySelection.test.js | 204 ++++ ui/src/common/useRefreshOnEvents.jsx | 109 ++ ui/src/common/useRefreshOnEvents.test.js | 233 ++++ ui/src/common/useResourceRefresh.jsx | 61 + ui/src/dataProvider/wrapperDataProvider.js | 124 +- ui/src/i18n/en.json | 79 +- ui/src/layout/Menu.jsx | 2 + ui/src/library/DeleteLibraryButton.jsx | 80 ++ ui/src/library/LibraryCreate.jsx | 84 ++ ui/src/library/LibraryEdit.jsx | 274 +++++ ui/src/library/LibraryList.jsx | 56 + ui/src/library/index.js | 11 + ui/src/missing/MissingFilesList.jsx | 25 + ui/src/reducers/index.js | 1 + ui/src/reducers/libraryReducer.js | 31 + ui/src/store/createAdminStore.js | 1 + ui/src/user/LibrarySelectionField.jsx | 55 + ui/src/user/LibrarySelectionField.test.jsx | 168 +++ ui/src/user/UserCreate.jsx | 38 +- ui/src/user/UserEdit.jsx | 32 + ui/src/user/UserEdit.test.jsx | 130 +++ ui/src/user/userValidation.js | 19 + ui/src/user/userValidation.test.js | 70 ++ ui/src/utils/formatters.js | 41 + ui/src/utils/formatters.test.js | 81 ++ utils/files_test.go | 178 +++ 127 files changed, 12196 insertions(+), 959 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 core/library.go create mode 100644 core/library_test.go create mode 100644 core/mock_library_service.go create mode 100644 db/migrations/20250701010108_add_multi_library_support.go create mode 100644 model/user_test.go create mode 100644 persistence/genre_repository_test.go create mode 100644 persistence/tag_library_filtering_test.go create mode 100644 persistence/tag_repository_test.go create mode 100644 scanner/scanner_multilibrary_test.go create mode 100644 server/nativeapi/library.go create mode 100644 server/nativeapi/library_test.go create mode 100644 server/subsonic/browsing_test.go create mode 100644 server/subsonic/searching_test.go create mode 100644 ui/src/actions/library.js create mode 100644 ui/src/common/LibrarySelector.jsx create mode 100644 ui/src/common/LibrarySelector.test.jsx create mode 100644 ui/src/common/SelectLibraryInput.jsx create mode 100644 ui/src/common/SelectLibraryInput.test.jsx create mode 100644 ui/src/common/useLibrarySelection.js create mode 100644 ui/src/common/useLibrarySelection.test.js create mode 100644 ui/src/common/useRefreshOnEvents.jsx create mode 100644 ui/src/common/useRefreshOnEvents.test.js create mode 100644 ui/src/library/DeleteLibraryButton.jsx create mode 100644 ui/src/library/LibraryCreate.jsx create mode 100644 ui/src/library/LibraryEdit.jsx create mode 100644 ui/src/library/LibraryList.jsx create mode 100644 ui/src/library/index.js create mode 100644 ui/src/reducers/libraryReducer.js create mode 100644 ui/src/user/LibrarySelectionField.jsx create mode 100644 ui/src/user/LibrarySelectionField.test.jsx create mode 100644 ui/src/user/UserEdit.test.jsx create mode 100644 ui/src/user/userValidation.js create mode 100644 ui/src/user/userValidation.test.js create mode 100644 utils/files_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 451ffb2bd..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,53 +0,0 @@ -# Navidrome Code Guidelines - -This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations. - -## Code Standards - -### Backend (Go) -- Follow standard Go conventions and idioms -- Use context propagation for cancellation signals -- Write unit tests for new functionality using Ginkgo/Gomega -- Use mutex appropriately for concurrent operations -- Implement interfaces for dependencies to facilitate testing - -### Frontend (React) -- Use functional components with hooks -- Follow React best practices for state management -- Implement PropTypes for component properties -- Prefer using React-Admin and Material-UI components -- Icons should be imported from `react-icons` only -- Follow existing patterns for API interaction - -## Repository Structure -- `core/`: Server-side business logic (artwork handling, playback, etc.) -- `ui/`: React frontend components -- `model/`: Data models and repository interfaces -- `server/`: API endpoints and server implementation -- `utils/`: Shared utility functions -- `persistence/`: Database access layer -- `scanner/`: Music library scanning functionality - -## Key Guidelines -1. Maintain cache management patterns for performance -2. Follow the existing concurrency patterns (mutex, atomic) -3. Use the testing framework appropriately (Ginkgo/Gomega for Go) -4. Keep UI components focused and reusable -5. Document configuration options in code -6. Consider performance implications when working with music libraries -7. Follow existing error handling patterns -8. Ensure compatibility with external services (LastFM, Spotify, Deezer) - -## Development Workflow -- Test changes thoroughly, especially around concurrent operations -- Validate both backend and frontend interactions -- Consider how changes will affect user experience and performance -- Test with different music library sizes and configurations -- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues - -## Important commands -- `make build`: Build the application -- `make test`: Run Go tests -- To run tests for a specific package, use `make test PKG=./pkgname/...` -- `make lintall`: Run linters -- `make format`: Format code diff --git a/cmd/root.go b/cmd/root.go index df39f50a6..9618b16e6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -110,7 +110,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) { func startServer(ctx context.Context) func() error { return func() error { a := CreateServer() - a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter()) + a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx)) a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx)) a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter()) if conf.Server.LastFM.Enabled { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index dc558c393..ee5fd025e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -52,13 +52,25 @@ func CreateServer() *server.Server { return serverServer } -func CreateNativeAPIRouter() *nativeapi.Router { +func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) playlists := core.NewPlaylists(dataStore) insights := metrics.GetInstance(dataStore) - router := nativeapi.New(dataStore, share, playlists, insights) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + 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) return router } @@ -164,7 +176,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) - watcher := scanner.NewWatcher(dataStore, scannerScanner) + watcher := scanner.GetWatcher(dataStore, scannerScanner) return watcher } @@ -185,7 +197,7 @@ func getPluginManager() plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index e2bc6cd1b..ec469b8be 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -38,12 +38,14 @@ var allProviders = wire.NewSet( listenbrainz.NewRouter, events.GetBroker, scanner.New, - scanner.NewWatcher, + scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), + wire.Bind(new(core.Scanner), new(scanner.Scanner)), + wire.Bind(new(core.Watcher), new(scanner.Watcher)), ) func CreateDataStore() model.DataStore { @@ -58,7 +60,7 @@ func CreateServer() *server.Server { )) } -func CreateNativeAPIRouter() *nativeapi.Router { +func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { panic(wire.Build( allProviders, )) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index 2e60ca00b..909d299d8 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -96,8 +96,11 @@ func (a *cacheWarmer) run(ctx context.Context) { // If cache not available, keep waiting if !a.cache.Available(ctx) { - if len(a.buffer) > 0 { - log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", len(a.buffer)) + a.mutex.Lock() + bufferLen := len(a.buffer) + a.mutex.Unlock() + if bufferLen > 0 { + log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) } continue } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index d35fb6e82..4125d6de0 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -80,6 +80,7 @@ var _ = Describe("CacheWarmer", func() { }) It("adds multiple items to buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw.PreCache(model.MustParseArtworkID("al-1")) cw.PreCache(model.MustParseArtworkID("al-2")) @@ -214,3 +215,7 @@ func (f *mockFileCache) SetDisabled(v bool) { f.disabled.Store(v) f.ready.Store(true) } + +func (f *mockFileCache) SetReady(v bool) { + f.ready.Store(v) +} diff --git a/core/library.go b/core/library.go new file mode 100644 index 000000000..7abd35c8f --- /dev/null +++ b/core/library.go @@ -0,0 +1,412 @@ +package core + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/slice" +) + +// Scanner interface for triggering scans +type Scanner interface { + ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) +} + +// Watcher interface for managing file system watchers +type Watcher interface { + Watch(ctx context.Context, lib *model.Library) error + StopWatching(ctx context.Context, libraryID int) error +} + +// Library provides business logic for library management and user-library associations +type Library interface { + GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) + SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error + ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error + + NewRepository(ctx context.Context) rest.Repository +} + +type libraryService struct { + ds model.DataStore + scanner Scanner + watcher Watcher + broker events.Broker +} + +// NewLibrary creates a new Library service +func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library { + return &libraryService{ + ds: ds, + scanner: scanner, + watcher: watcher, + broker: broker, + } +} + +// User-library association operations + +func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { + // Verify user exists + if _, err := s.ds.User(ctx).Get(userID); err != nil { + return nil, err + } + + return s.ds.User(ctx).GetUserLibraries(userID) +} + +func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { + // Verify user exists + user, err := s.ds.User(ctx).Get(userID) + if err != nil { + return err + } + + // Admin users get all libraries automatically - don't allow manual assignment + if user.IsAdmin { + return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation) + } + + // Regular users must have at least one library + if len(libraryIDs) == 0 { + return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation) + } + + // Validate all library IDs exist + if len(libraryIDs) > 0 { + if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil { + return err + } + } + + // Set user libraries + err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs) + if err != nil { + return fmt.Errorf("error setting user libraries: %w", err) + } + + // Send refresh event to all clients + event := &events.RefreshResource{} + libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) }) + event = event.With("user", userID).With("library", libIDs...) + s.broker.SendBroadcastMessage(ctx, event) + return nil +} + +func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error { + user, ok := request.UserFrom(ctx) + if !ok { + return fmt.Errorf("user not found in context") + } + + // Admin users have access to all libraries + if user.IsAdmin { + return nil + } + + // Check if user has explicit access to this library + libraries, err := s.ds.User(ctx).GetUserLibraries(userID) + if err != nil { + log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err) + return fmt.Errorf("error checking library access: %w", err) + } + + for _, lib := range libraries { + if lib.ID == libraryID { + return nil + } + } + + return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID) +} + +// REST repository wrapper + +func (s *libraryService) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.Library(ctx) + wrapper := &libraryRepositoryWrapper{ + ctx: ctx, + LibraryRepository: repo, + Repository: repo.(rest.Repository), + ds: s.ds, + scanner: s.scanner, + watcher: s.watcher, + broker: s.broker, + } + return wrapper +} + +type libraryRepositoryWrapper struct { + rest.Repository + model.LibraryRepository + ctx context.Context + ds model.DataStore + scanner Scanner + watcher Watcher + broker events.Broker +} + +func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + if err := r.validateLibrary(lib); err != nil { + return "", err + } + + err := r.LibraryRepository.Put(lib) + if err != nil { + return "", r.mapError(err) + } + + // Start watcher and trigger scan after successful library creation + if r.watcher != nil { + if err := r.watcher.Watch(r.ctx, lib); err != nil { + log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } + } + + if r.scanner != nil { + go r.triggerScan(lib, "new") + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID))) + log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) + } + + return strconv.Itoa(lib.ID), nil +} + +func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error { + lib := entity.(*model.Library) + libID, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("invalid library ID: %s", id) + } + + lib.ID = libID + if err := r.validateLibrary(lib); err != nil { + return err + } + + // Get the original library to check if path changed + originalLib, err := r.Get(libID) + if err != nil { + return r.mapError(err) + } + + pathChanged := originalLib.Path != lib.Path + + err = r.LibraryRepository.Put(lib) + if err != nil { + return r.mapError(err) + } + + // Restart watcher and trigger scan if path was updated + if pathChanged { + if r.watcher != nil { + if err := r.watcher.Watch(r.ctx, lib); err != nil { + log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } + } + + if r.scanner != nil { + go r.triggerScan(lib, "updated") + } + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) + } + + return nil +} + +func (r *libraryRepositoryWrapper) Delete(id string) error { + libID, err := strconv.Atoi(id) + if err != nil { + return &rest.ValidationError{Errors: map[string]string{ + "id": "invalid library ID format", + }} + } + + // Get library info before deletion for logging + lib, err := r.Get(libID) + if err != nil { + return r.mapError(err) + } + + err = r.LibraryRepository.Delete(libID) + if err != nil { + return r.mapError(err) + } + + // Stop watcher and trigger scan after successful library deletion to clean up orphaned data + if r.watcher != nil { + if err := r.watcher.StopWatching(r.ctx, libID); err != nil { + log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) + } + } + + if r.scanner != nil { + go r.triggerScan(lib, "deleted") + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) + } + + return nil +} + +// Helper methods + +func (r *libraryRepositoryWrapper) mapError(err error) error { + if err == nil { + return nil + } + + errStr := err.Error() + + // Handle database constraint violations. + // TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API + if strings.Contains(errStr, "UNIQUE constraint failed") { + if strings.Contains(errStr, "library.name") { + return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}} + } + if strings.Contains(errStr, "library.path") { + return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}} + } + } + + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } +} + +func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error { + validationErrors := make(map[string]string) + + if library.Name == "" { + validationErrors["name"] = "ra.validation.required" + } + + if library.Path == "" { + validationErrors["path"] = "ra.validation.required" + } else { + // Validate path format and accessibility + if err := r.validateLibraryPath(library); err != nil { + validationErrors["path"] = err.Error() + } + } + + if len(validationErrors) > 0 { + return &rest.ValidationError{Errors: validationErrors} + } + + return nil +} + +func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error { + // Validate path format + if !filepath.IsAbs(library.Path) { + return fmt.Errorf("library path must be absolute") + } + + // Clean the path to normalize it + cleanPath := filepath.Clean(library.Path) + library.Path = cleanPath + + // Check if path exists and is accessible using storage abstraction + fileStore, err := storage.For(library.Path) + if err != nil { + return fmt.Errorf("invalid storage scheme: %w", err) + } + + fsys, err := fileStore.FS() + if err != nil { + log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err) + return fmt.Errorf("resources.library.validation.pathInvalid") + } + + // Check if root directory exists + info, err := fs.Stat(fsys, ".") + if err != nil { + // Parse the error message to check for "not a directory" + log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err) + errStr := err.Error() + if strings.Contains(errStr, "not a directory") || + strings.Contains(errStr, "The directory name is invalid.") { + return fmt.Errorf("resources.library.validation.pathNotDirectory") + } else if os.IsNotExist(err) { + return fmt.Errorf("resources.library.validation.pathNotFound") + } else if os.IsPermission(err) { + return fmt.Errorf("resources.library.validation.pathNotAccessible") + } else { + return fmt.Errorf("resources.library.validation.pathInvalid") + } + } + + if !info.IsDir() { + return fmt.Errorf("resources.library.validation.pathNotDirectory") + } + + return nil +} + +func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error { + if len(libraryIDs) == 0 { + return nil + } + + // Use CountAll to efficiently validate library IDs exist + count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"id": libraryIDs}, + }) + if err != nil { + return fmt.Errorf("error validating library IDs: %w", err) + } + + if int(count) != len(libraryIDs) { + return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation) + } + + return nil +} + +func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) { + log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) + start := time.Now() + warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library + if err != nil { + log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) + } else { + log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) + } +} diff --git a/core/library_test.go b/core/library_test.go new file mode 100644 index 000000000..bfbb4300a --- /dev/null +++ b/core/library_test.go @@ -0,0 +1,980 @@ +package core_test + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/deluan/rest" + _ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// These tests require the local storage adapter and the taglib extractor to be registered. +var _ = Describe("Library Service", func() { + var service core.Library + var ds *tests.MockDataStore + var libraryRepo *tests.MockLibraryRepo + var userRepo *tests.MockedUserRepo + var ctx context.Context + var tempDir string + var scanner *mockScanner + var watcherManager *mockWatcherManager + var broker *mockEventBroker + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + ds = &tests.MockDataStore{} + libraryRepo = &tests.MockLibraryRepo{} + userRepo = tests.CreateMockUserRepo() + ds.MockedLibrary = libraryRepo + ds.MockedUser = userRepo + + // Create a mock scanner that tracks calls + scanner = &mockScanner{} + // Create a mock watcher manager + watcherManager = &mockWatcherManager{ + libraryStates: make(map[int]model.Library), + } + // Create a mock event broker + broker = &mockEventBroker{} + service = core.NewLibrary(ds, scanner, watcherManager, broker) + ctx = context.Background() + + // Create a temporary directory for testing valid paths + var err error + tempDir, err = os.MkdirTemp("", "navidrome-library-test-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + os.RemoveAll(tempDir) + }) + }) + + Describe("Library CRUD Operations", func() { + var repo rest.Persistable + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + }) + + Describe("Create", func() { + It("creates a new library successfully", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data[1].Name).To(Equal("New Library")) + Expect(libraryRepo.Data[1].Path).To(Equal(tempDir)) + }) + + It("fails when library name is empty", func() { + library := &model.Library{Path: tempDir} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ra.validation.required")) + }) + + It("fails when library path is empty", func() { + library := &model.Library{Name: "Test"} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ra.validation.required")) + }) + + It("fails when library path is not absolute", func() { + library := &model.Library{Name: "Test", Path: "relative/path"} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) + }) + + Context("Database constraint violations", func() { + BeforeEach(func() { + // Set up an existing library that will cause constraint violations + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Existing Library", Path: tempDir}, + }) + }) + + AfterEach(func() { + // Reset custom PutFn after each test + libraryRepo.PutFn = nil + }) + + It("handles name uniqueness constraint violation from database", func() { + // Create the directory that will be used for the test + otherTempDir, err := os.MkdirTemp("", "navidrome-other-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(otherTempDir) }) + + // Try to create another library with the same name + library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir} + + // Mock the repository to return a UNIQUE constraint error + libraryRepo.PutFn = func(library *model.Library) error { + return errors.New("UNIQUE constraint failed: library.name") + } + + _, err = repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique")) + }) + + It("handles path uniqueness constraint violation from database", func() { + // Try to create another library with the same path + library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir} + + // Mock the repository to return a UNIQUE constraint error + libraryRepo.PutFn = func(library *model.Library) error { + return errors.New("UNIQUE constraint failed: library.path") + } + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique")) + }) + }) + }) + + Describe("Update", func() { + BeforeEach(func() { + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + }) + + It("updates an existing library successfully", func() { + newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(newTempDir) }) + + library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} + + err = repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library")) + Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) + }) + + It("fails when library doesn't exist", func() { + // Create a unique temporary directory to avoid path conflicts + uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(uniqueTempDir) }) + + library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir} + + err = repo.Update("999", library) + + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("fails when library name is empty", func() { + library := &model.Library{ID: 1, Path: tempDir} + + err := repo.Update("1", library) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ra.validation.required")) + }) + + It("cleans and normalizes the path on update", func() { + unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir) + library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath} + + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath))) + }) + + It("allows updating library with same name (no change)", func() { + // Set up a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + + // Update the library keeping the same name (should be allowed) + library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} + + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("allows updating library with same path (no change)", func() { + // Set up a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + + // Update the library keeping the same path (should be allowed) + library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} + + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + }) + + Context("Database constraint violations during update", func() { + BeforeEach(func() { + // Reset any custom PutFn from previous tests + libraryRepo.PutFn = nil + }) + + It("handles name uniqueness constraint violation during update", func() { + // Create additional temp directory for the test + otherTempDir, err := os.MkdirTemp("", "navidrome-other-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(otherTempDir) }) + + // Set up two libraries + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Library One", Path: tempDir}, + {ID: 2, Name: "Library Two", Path: otherTempDir}, + }) + + // Mock database constraint violation + libraryRepo.PutFn = func(library *model.Library) error { + return errors.New("UNIQUE constraint failed: library.name") + } + + // Try to update library 2 to have the same name as library 1 + library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir} + + err = repo.Update("2", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique")) + }) + + It("handles path uniqueness constraint violation during update", func() { + // Create additional temp directory for the test + otherTempDir, err := os.MkdirTemp("", "navidrome-other-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(otherTempDir) }) + + // Set up two libraries + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Library One", Path: tempDir}, + {ID: 2, Name: "Library Two", Path: otherTempDir}, + }) + + // Mock database constraint violation + libraryRepo.PutFn = func(library *model.Library) error { + return errors.New("UNIQUE constraint failed: library.path") + } + + // Try to update library 2 to have the same path as library 1 + library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir} + + err = repo.Update("2", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique")) + }) + }) + }) + + Describe("Path Validation", func() { + Context("Create operation", func() { + It("fails when path is not absolute", func() { + library := &model.Library{Name: "Test", Path: "relative/path"} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) + }) + + It("fails when path does not exist", func() { + nonExistentPath := filepath.Join(tempDir, "nonexistent") + library := &model.Library{Name: "Test", Path: nonExistentPath} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) + }) + + It("fails when path is a file instead of directory", func() { + testFile := filepath.Join(tempDir, "testfile.txt") + err := os.WriteFile(testFile, []byte("test"), 0600) + Expect(err).NotTo(HaveOccurred()) + + library := &model.Library{Name: "Test", Path: testFile} + + _, err = repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory")) + }) + + It("fails when path is not accessible due to permissions", func() { + Skip("Permission tests are environment-dependent and may fail in CI") + // This test is skipped because creating a directory with no read permissions + // is complex and may not work consistently across different environments + }) + + It("handles multiple validation errors", func() { + library := &model.Library{Name: "", Path: "relative/path"} + + _, err := repo.Save(library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors).To(HaveKey("name")) + Expect(validationErr.Errors).To(HaveKey("path")) + Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required")) + Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) + }) + }) + + Context("Update operation", func() { + BeforeEach(func() { + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + }) + + It("fails when updated path is not absolute", func() { + library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"} + + err := repo.Update("1", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) + }) + + It("allows updating library with same name (no change)", func() { + // Set up a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + + // Update the library keeping the same name (should be allowed) + library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} + + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails when updated path does not exist", func() { + nonExistentPath := filepath.Join(tempDir, "nonexistent") + library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath} + + err := repo.Update("1", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) + }) + + It("fails when updated path is a file instead of directory", func() { + testFile := filepath.Join(tempDir, "updatefile.txt") + err := os.WriteFile(testFile, []byte("test"), 0600) + Expect(err).NotTo(HaveOccurred()) + + library := &model.Library{ID: 1, Name: "Test", Path: testFile} + + err = repo.Update("1", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory")) + }) + + It("handles multiple validation errors on update", func() { + // Try to update with empty name and invalid path + library := &model.Library{ID: 1, Name: "", Path: "relative/path"} + + err := repo.Update("1", library) + + Expect(err).To(HaveOccurred()) + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + Expect(validationErr.Errors).To(HaveKey("name")) + Expect(validationErr.Errors).To(HaveKey("path")) + Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required")) + Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) + }) + }) + }) + + Describe("Delete", func() { + BeforeEach(func() { + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Library to Delete", Path: tempDir}, + }) + }) + + It("deletes an existing library successfully", func() { + err := repo.Delete("1") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data).To(HaveLen(0)) + }) + + It("fails when library doesn't exist", func() { + err := repo.Delete("999") + + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + }) + + Describe("User-Library Association Operations", func() { + var regularUser, adminUser *model.User + + BeforeEach(func() { + regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false} + adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true} + + userRepo.Data = map[string]*model.User{ + "regular": regularUser, + "admin": adminUser, + } + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Library 1", Path: "/music1"}, + {ID: 2, Name: "Library 2", Path: "/music2"}, + {ID: 3, Name: "Library 3", Path: "/music3"}, + }) + }) + + Describe("GetUserLibraries", func() { + It("returns user's libraries", func() { + userRepo.UserLibraries = map[string][]int{ + "user1": {1}, + } + + result, err := service.GetUserLibraries(ctx, "user1") + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal(1)) + }) + + It("fails when user doesn't exist", func() { + _, err := service.GetUserLibraries(ctx, "nonexistent") + + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + + Describe("SetUserLibraries", func() { + It("sets libraries for regular user successfully", func() { + err := service.SetUserLibraries(ctx, "user1", []int{1, 2}) + + Expect(err).NotTo(HaveOccurred()) + libraries := userRepo.UserLibraries["user1"] + Expect(libraries).To(HaveLen(2)) + }) + + It("fails when user doesn't exist", func() { + err := service.SetUserLibraries(ctx, "nonexistent", []int{1}) + + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("fails when trying to set libraries for admin user", func() { + err := service.SetUserLibraries(ctx, "admin1", []int{1}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users")) + }) + + It("fails when no libraries provided for regular user", func() { + err := service.SetUserLibraries(ctx, "user1", []int{}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users")) + }) + + It("fails when library doesn't exist", func() { + err := service.SetUserLibraries(ctx, "user1", []int{999}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid")) + }) + + It("fails when some libraries don't exist", func() { + err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid")) + }) + }) + + Describe("ValidateLibraryAccess", func() { + Context("admin user", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, *adminUser) + }) + + It("allows access to any library", func() { + err := service.ValidateLibraryAccess(ctx, "admin1", 1) + + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("regular user", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, *regularUser) + userRepo.UserLibraries = map[string][]int{ + "user1": {1}, + } + }) + + It("allows access to user's libraries", func() { + err := service.ValidateLibraryAccess(ctx, "user1", 1) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("denies access to libraries user doesn't have", func() { + err := service.ValidateLibraryAccess(ctx, "user1", 2) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("user does not have access to library 2")) + }) + }) + + Context("no user in context", func() { + It("fails with user not found error", func() { + err := service.ValidateLibraryAccess(ctx, "user1", 1) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("user not found in context")) + }) + }) + }) + }) + + Describe("Scan Triggering", func() { + var repo rest.Persistable + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + }) + + It("triggers scan when creating a new library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + Expect(err).NotTo(HaveOccurred()) + + // Wait briefly for the goroutine to complete + Eventually(func() int { + return scanner.len() + }, "1s", "10ms").Should(Equal(1)) + + // Verify scan was called with correct parameters + Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + }) + + It("triggers scan when updating library path", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Create a new temporary directory for the update + newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(newTempDir) }) + + // Update the library with a new path + library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} + err = repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Wait briefly for the goroutine to complete + Eventually(func() int { + return scanner.len() + }, "1s", "10ms").Should(Equal(1)) + + // Verify scan was called with correct parameters + Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + }) + + It("does not trigger scan when updating library without path change", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Update the library name only (same path) + library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} + err := repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Wait a bit to ensure no scan was triggered + Consistently(func() int { + return scanner.len() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("does not trigger scan when library creation fails", func() { + // Try to create library with invalid data (empty name) + library := &model.Library{Path: tempDir} + + _, err := repo.Save(library) + Expect(err).To(HaveOccurred()) + + // Ensure no scan was triggered since creation failed + Consistently(func() int { + return scanner.len() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("does not trigger scan when library update fails", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Try to update with invalid data (empty name) + library := &model.Library{ID: 1, Name: "", Path: tempDir} + err := repo.Update("1", library) + Expect(err).To(HaveOccurred()) + + // Ensure no scan was triggered since update failed + Consistently(func() int { + return scanner.len() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("triggers scan when deleting a library", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Library to Delete", Path: tempDir}, + }) + + // Delete the library + err := repo.Delete("1") + Expect(err).NotTo(HaveOccurred()) + + // Wait briefly for the goroutine to complete + Eventually(func() int { + return scanner.len() + }, "1s", "10ms").Should(Equal(1)) + + // Verify scan was called with correct parameters + Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan + }) + + It("does not trigger scan when library deletion fails", func() { + // Try to delete a non-existent library + err := repo.Delete("999") + Expect(err).To(HaveOccurred()) + + // Ensure no scan was triggered since deletion failed + Consistently(func() int { + return scanner.len() + }, "100ms", "10ms").Should(Equal(0)) + }) + + Context("Watcher Integration", func() { + It("starts watcher when creating a new library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + Expect(err).NotTo(HaveOccurred()) + + // Verify watcher was started + Eventually(func() int { + return watcherManager.lenStarted() + }, "1s", "10ms").Should(Equal(1)) + + Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1)) + Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library")) + Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir)) + }) + + It("restarts watcher when library path is updated", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Simulate that this library already has a watcher + watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir}) + + // Create a new temp directory for the update + newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(newTempDir) }) + + // Update library with new path + library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} + err = repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Verify watcher was restarted + Eventually(func() int { + return watcherManager.lenRestarted() + }, "1s", "10ms").Should(Equal(1)) + + Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1)) + Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir)) + }) + + It("does not restart watcher when only library name is updated", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + // Update library with same path but different name + library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} + err := repo.Update("1", library) + Expect(err).NotTo(HaveOccurred()) + + // Verify watcher was NOT restarted (since path didn't change) + Consistently(func() int { + return watcherManager.lenRestarted() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("stops watcher when library is deleted", func() { + // Set up a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + + err := repo.Delete("1") + Expect(err).NotTo(HaveOccurred()) + + // Verify watcher was stopped + Eventually(func() int { + return watcherManager.lenStopped() + }, "1s", "10ms").Should(Equal(1)) + + Expect(watcherManager.StoppedWatchers[0]).To(Equal(1)) + }) + + It("does not stop watcher when library deletion fails", func() { + // Set up a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library", Path: tempDir}, + }) + + // Mock deletion to fail by trying to delete non-existent library + err := repo.Delete("999") + Expect(err).To(HaveOccurred()) + + // Verify watcher was NOT stopped since deletion failed + Consistently(func() int { + return watcherManager.lenStopped() + }, "100ms", "10ms").Should(Equal(0)) + }) + }) + }) + + Describe("Event Broadcasting", func() { + var repo rest.Persistable + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + // Clear any events from broker + broker.Events = []events.Event{} + }) + + It("sends refresh event when creating a library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(library) + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + + It("sends refresh event when updating a library", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 1, Name: "Original Library", Path: tempDir}, + }) + + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + err := repo.Update("1", library) + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + + It("sends refresh event when deleting a library", func() { + // First create a library + libraryRepo.SetData(model.Libraries{ + {ID: 2, Name: "Library to Delete", Path: tempDir}, + }) + + err := repo.Delete("2") + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + }) +}) + +// mockScanner provides a simple mock implementation of core.Scanner for testing +type mockScanner struct { + ScanCalls []ScanCall + mu sync.RWMutex +} + +type ScanCall struct { + FullScan bool +} + +func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.ScanCalls = append(m.ScanCalls, ScanCall{ + FullScan: fullScan, + }) + return []string{}, nil +} + +func (m *mockScanner) len() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.ScanCalls) +} + +// mockWatcherManager provides a simple mock implementation of core.Watcher for testing +type mockWatcherManager struct { + StartedWatchers []model.Library + StoppedWatchers []int + RestartedWatchers []model.Library + libraryStates map[int]model.Library // Track which libraries we know about + mu sync.RWMutex +} + +func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if we already know about this library ID + if _, exists := m.libraryStates[lib.ID]; exists { + // This is a restart - the library already existed + // Update our tracking and record the restart + for i, startedLib := range m.StartedWatchers { + if startedLib.ID == lib.ID { + m.StartedWatchers[i] = *lib + break + } + } + m.RestartedWatchers = append(m.RestartedWatchers, *lib) + m.libraryStates[lib.ID] = *lib + return nil + } + + // This is a new library - first time we're seeing it + m.StartedWatchers = append(m.StartedWatchers, *lib) + m.libraryStates[lib.ID] = *lib + return nil +} + +func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error { + m.mu.Lock() + defer m.mu.Unlock() + m.StoppedWatchers = append(m.StoppedWatchers, libraryID) + return nil +} + +func (m *mockWatcherManager) lenStarted() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.StartedWatchers) +} + +func (m *mockWatcherManager) lenStopped() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.StoppedWatchers) +} + +func (m *mockWatcherManager) lenRestarted() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.RestartedWatchers) +} + +// simulateExistingLibrary simulates the scenario where a library already exists +// and has a watcher running (used by tests to set up the initial state) +func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) { + m.mu.Lock() + defer m.mu.Unlock() + m.libraryStates[lib.ID] = lib +} + +// mockEventBroker provides a mock implementation of events.Broker for testing +type mockEventBroker struct { + http.Handler + Events []events.Event + mu sync.RWMutex +} + +func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) { + m.mu.Lock() + defer m.mu.Unlock() + m.Events = append(m.Events, event) +} + +func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) { + m.mu.Lock() + defer m.mu.Unlock() + m.Events = append(m.Events, event) +} diff --git a/core/mock_library_service.go b/core/mock_library_service.go new file mode 100644 index 000000000..56f2abd4c --- /dev/null +++ b/core/mock_library_service.go @@ -0,0 +1,46 @@ +package core + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" +) + +// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo +// that implements the core.Library interface for testing +type MockLibraryWrapper struct { + *tests.MockLibraryRepo +} + +// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface +type MockLibraryRestAdapter struct { + *tests.MockLibraryRepo +} + +// NewMockLibraryService creates a new mock library service for testing +func NewMockLibraryService() Library { + repo := &tests.MockLibraryRepo{ + Data: make(map[int]model.Library), + } + // Set up default test data + repo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, + {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, + }) + return &MockLibraryWrapper{MockLibraryRepo: repo} +} + +func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository { + return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo} +} + +// rest.Repository interface implementation + +func (a *MockLibraryRestAdapter) Delete(id string) error { + return a.DeleteByStringID(id) +} + +var _ Library = (*MockLibraryWrapper)(nil) +var _ rest.Repository = (*MockLibraryRestAdapter)(nil) diff --git a/core/playlists.go b/core/playlists.go index 4cdab0d38..1d998f1e3 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -326,7 +326,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string, if needsTrackRefresh { pls, err = repo.GetWithTracks(playlistID, true, false) pls.RemoveTracks(idxToRemove) - pls.AddTracks(idsToAdd) + pls.AddMediaFilesByID(idsToAdd) } else { if len(idsToAdd) > 0 { _, err = tracks.Add(idsToAdd) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index e4e052779..6c017c0bc 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -74,8 +74,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } if conf.Server.EnableNowPlaying { m.OnExpiration(func(_ string, _ NowPlayingInfo) { - ctx := events.BroadcastToAll(context.Background()) - broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()}) + broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) }) } @@ -195,8 +194,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam ttl := time.Duration(remaining+5) * time.Second _ = p.playMap.AddWithTTL(playerId, info, ttl) if conf.Server.EnableNowPlaying { - ctx = events.BroadcastToAll(ctx) - p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) + p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } player, _ := request.PlayerFrom(ctx) if player.ScrobbleEnabled { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0447aa142..7b4785bb5 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -429,6 +429,12 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.events = append(f.events, event) } +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + func (f *fakeEventBroker) getEvents() []events.Event { f.mu.Lock() defer f.mu.Unlock() diff --git a/core/wire_providers.go b/core/wire_providers.go index 482cfbefe..ae365156a 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -17,6 +17,7 @@ var Set = wire.NewSet( NewPlayers, NewShare, NewPlaylists, + NewLibrary, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/db/migrations/20250701010108_add_multi_library_support.go b/db/migrations/20250701010108_add_multi_library_support.go new file mode 100644 index 000000000..654784d09 --- /dev/null +++ b/db/migrations/20250701010108_add_multi_library_support.go @@ -0,0 +1,119 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMultiLibrarySupport, downAddMultiLibrarySupport) +} + +func upAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + -- Create user_library association table + CREATE TABLE user_library ( + user_id VARCHAR(255) NOT NULL, + library_id INTEGER NOT NULL, + PRIMARY KEY (user_id, library_id), + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE + ); + -- Create indexes for performance + CREATE INDEX idx_user_library_user_id ON user_library(user_id); + CREATE INDEX idx_user_library_library_id ON user_library(library_id); + + -- Populate with existing users having access to library ID 1 (existing setup) + -- Admin users get access to all libraries, regular users get access to library 1 + INSERT INTO user_library (user_id, library_id) + SELECT u.id, 1 + FROM user u; + + -- Add total_duration column to library table + ALTER TABLE library ADD COLUMN total_duration real DEFAULT 0; + UPDATE library SET total_duration = ( + SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0 + ); + + -- Add default_new_users column to library table + ALTER TABLE library ADD COLUMN default_new_users boolean DEFAULT false; + -- Set library ID 1 (default library) as default for new users + UPDATE library SET default_new_users = true WHERE id = 1; + + -- Add stats column to library_artist junction table for per-library artist statistics + ALTER TABLE library_artist ADD COLUMN stats text DEFAULT '{}'; + + -- Migrate existing global artist stats to per-library format in library_artist table + -- For each library_artist association, copy the artist's global stats + UPDATE library_artist + SET stats = ( + SELECT COALESCE(artist.stats, '{}') + FROM artist + WHERE artist.id = library_artist.artist_id + ); + + -- Remove stats column from artist table to eliminate duplication + -- Stats are now stored per-library in library_artist table + ALTER TABLE artist DROP COLUMN stats; + + -- Create library_tag table for per-library tag statistics + CREATE TABLE library_tag ( + tag_id VARCHAR NOT NULL, + library_id INTEGER NOT NULL, + album_count INTEGER DEFAULT 0 NOT NULL, + media_file_count INTEGER DEFAULT 0 NOT NULL, + PRIMARY KEY (tag_id, library_id), + FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE + ); + + -- Create indexes for optimal query performance + CREATE INDEX idx_library_tag_tag_id ON library_tag(tag_id); + CREATE INDEX idx_library_tag_library_id ON library_tag(library_id); + + -- Migrate existing tag stats to per-library format in library_tag table + -- For existing installations, copy current global stats to library ID 1 (default library) + INSERT INTO library_tag (tag_id, library_id, album_count, media_file_count) + SELECT t.id, 1, t.album_count, t.media_file_count + FROM tag t + WHERE EXISTS (SELECT 1 FROM library WHERE id = 1); + + -- Remove global stats from tag table as they are now per-library + ALTER TABLE tag DROP COLUMN album_count; + ALTER TABLE tag DROP COLUMN media_file_count; + `) + + return err +} + +func downAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + -- Restore stats column to artist table before removing from library_artist + ALTER TABLE artist ADD COLUMN stats text DEFAULT '{}'; + + -- Restore global stats by aggregating from library_artist (simplified approach) + -- In a real rollback scenario, this might need more sophisticated logic + UPDATE artist + SET stats = ( + SELECT COALESCE(la.stats, '{}') + FROM library_artist la + WHERE la.artist_id = artist.id + LIMIT 1 + ); + + ALTER TABLE library_artist DROP COLUMN IF EXISTS stats; + DROP INDEX IF EXISTS idx_user_library_library_id; + DROP INDEX IF EXISTS idx_user_library_user_id; + DROP TABLE IF EXISTS user_library; + ALTER TABLE library DROP COLUMN IF EXISTS total_duration; + ALTER TABLE library DROP COLUMN IF EXISTS default_new_users; + + -- Drop library_tag table and its indexes + DROP INDEX IF EXISTS idx_library_tag_library_id; + DROP INDEX IF EXISTS idx_library_tag_tag_id; + DROP TABLE IF EXISTS library_tag; + `) + return err +} diff --git a/model/album.go b/model/album.go index c9dc022cb..a8dcfe682 100644 --- a/model/album.go +++ b/model/album.go @@ -14,6 +14,8 @@ type Album struct { ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` + LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` + LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` Name string `structs:"name" json:"name"` EmbedArtPath string `structs:"embed_art_path" json:"-"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants diff --git a/model/artist.go b/model/artist.go index 7f68f9787..309ee800f 100644 --- a/model/artist.go +++ b/model/artist.go @@ -78,7 +78,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) - GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error) + GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: RefreshPlayCounts() (int64, error) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index fdcd3828b..3699eb14a 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -53,6 +53,7 @@ var fieldMap = map[string]*mappedField{ "mbz_recording_id": {field: "media_file.mbz_recording_id"}, "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, + "library_id": {field: "media_file.library_id", numeric: true}, // special fields "random": {field: "", order: "random()"}, // pseudo-field for random sorting diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 95f9fc5f4..ee716a9cd 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -29,7 +29,11 @@ var _ = Describe("Operators", func() { }, Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), + Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), + Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), diff --git a/model/errors.go b/model/errors.go index ff4be5723..41029d316 100644 --- a/model/errors.go +++ b/model/errors.go @@ -8,4 +8,5 @@ var ( ErrNotAuthorized = errors.New("not authorized") ErrExpired = errors.New("access expired") ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") ) diff --git a/model/folder.go b/model/folder.go index 12e0d711e..f715f8c11 100644 --- a/model/folder.go +++ b/model/folder.go @@ -17,7 +17,7 @@ import ( type Folder struct { ID string `structs:"id"` LibraryID int `structs:"library_id"` - LibraryPath string `structs:"-" json:"-" hash:"-"` + LibraryPath string `structs:"-" json:"-" hash:"ignore"` Path string `structs:"path"` Name string `structs:"name"` ParentID string `structs:"parent_id"` diff --git a/model/library.go b/model/library.go index fda22f19f..bcb2864c8 100644 --- a/model/library.go +++ b/model/library.go @@ -2,40 +2,57 @@ package model import ( "time" + + "github.com/navidrome/navidrome/utils/slice" ) type Library struct { - ID int - Name string - Path string - RemotePath string - LastScanAt time.Time - LastScanStartedAt time.Time - FullScanInProgress bool - UpdatedAt time.Time - CreatedAt time.Time - - TotalSongs int - TotalAlbums int - TotalArtists int - TotalFolders int - TotalFiles int - TotalMissingFiles int - TotalSize int64 + ID int `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Path string `json:"path" db:"path"` + RemotePath string `json:"remotePath" db:"remote_path"` + LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"` + LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"` + FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + TotalSongs int `json:"totalSongs" db:"total_songs"` + TotalAlbums int `json:"totalAlbums" db:"total_albums"` + TotalArtists int `json:"totalArtists" db:"total_artists"` + TotalFolders int `json:"totalFolders" db:"total_folders"` + TotalFiles int `json:"totalFiles" db:"total_files"` + TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"` + TotalSize int64 `json:"totalSize" db:"total_size"` + TotalDuration float64 `json:"totalDuration" db:"total_duration"` + DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` } +const ( + DefaultLibraryID = 1 + DefaultLibraryName = "Music Library" +) + type Libraries []Library +func (l Libraries) IDs() []int { + return slice.Map(l, func(lib Library) int { return lib.ID }) +} + type LibraryRepository interface { Get(id int) (*Library, error) // GetPath returns the path of the library with the given ID. // Its implementation must be optimized to avoid unnecessary queries. GetPath(id int) (string, error) GetAll(...QueryOptions) (Libraries, error) + CountAll(...QueryOptions) (int64, error) Put(*Library) error + Delete(id int) error StoreMusicFolder() error AddArtist(id int, artistID string) error + // User-library association methods + GetUsersWithLibraryAccess(libraryID int) (Users, error) + // TODO These methods should be moved to a core service ScanBegin(id int, fullScan bool) error ScanEnd(id int) error diff --git a/model/mediafile.go b/model/mediafile.go index d29a2a509..0ef26d746 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -26,7 +26,8 @@ type MediaFile struct { ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"` - LibraryPath string `structs:"-" json:"libraryPath" hash:"-"` + LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` + LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` Path string `structs:"path" json:"path" hash:"ignore"` Title string `structs:"title" json:"title"` @@ -367,6 +368,8 @@ type MediaFileRepository interface { MarkMissing(bool, ...*MediaFile) error MarkMissingByFolder(missing bool, folderIDs ...string) error GetMissingAndMatching(libId int) (MediaFileCursor, error) + FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error) + FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error) AnnotatedRepository BookmarkableRepository diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go index 25025ea19..0a3bf0bf3 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -14,11 +14,15 @@ import ( // These are the legacy ID functions that were used in the original Navidrome ID generation. // They are kept here for backwards compatibility with existing databases. -func legacyTrackID(mf model.MediaFile) string { - return fmt.Sprintf("%x", md5.Sum([]byte(mf.Path))) +func legacyTrackID(mf model.MediaFile, prependLibId bool) string { + id := mf.Path + if prependLibId && mf.LibraryID != model.DefaultLibraryID { + id = fmt.Sprintf("%d\\%s", mf.LibraryID, id) + } + return fmt.Sprintf("%x", md5.Sum([]byte(id))) } -func legacyAlbumID(md Metadata) string { +func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { releaseDate := legacyReleaseDate(md) albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md))) if !conf.Server.Scanner.GroupAlbumReleases { @@ -26,6 +30,9 @@ func legacyAlbumID(md Metadata) string { albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate) } } + if prependLibId && mf.LibraryID != model.DefaultLibraryID { + albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath) + } return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) } diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 591b618a3..c64e8c724 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -7,9 +7,9 @@ import ( "math" "strconv" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/str" ) @@ -77,7 +77,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { // Persistent IDs mf.PID = md.trackPID(mf) - mf.AlbumID = md.albumID(mf) + mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) // BFR These IDs will go away once the UI handle multiple participants. // BFR For Legacy Subsonic compatibility, we will set them in the API handlers @@ -104,8 +104,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { } func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string { - getPID := createGetPID(id.NewHash) - return getPID(mf, md, pidConf) + return md.albumID(mf, pidConf) } func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index 95e93c2fa..b45882946 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -2,6 +2,7 @@ package metadata import ( "cmp" + "fmt" "path/filepath" "strings" @@ -21,13 +22,15 @@ type hashFunc = func(...string) string // Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. // For each field, it gets all its attributes values and concatenates them, then hashes the result. // If a field is empty, it is skipped and the function looks for the next field. -func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string { - var getPID func(mf model.MediaFile, md Metadata, spec string) string - getAttr := func(mf model.MediaFile, md Metadata, attr string) string { +type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string + +func createGetPID(hash hashFunc) getPIDFunc { + var getPID getPIDFunc + getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string { attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": - return getPID(mf, md, conf.Server.PID.Album) + return getPID(mf, md, conf.Server.PID.Album, prependLibId) case "folder": return filepath.Dir(mf.Path) case "albumartistid": @@ -39,14 +42,14 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri } return md.String(model.TagName(attr)) } - getPID = func(mf model.MediaFile, md Metadata, spec string) string { + getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { pid := "" fields := strings.Split(spec, "|") for _, field := range fields { attributes := strings.Split(field, ",") hasValue := false values := slice.Map(attributes, func(attr string) string { - v := getAttr(mf, md, attr) + v := getAttr(mf, md, attr, prependLibId) if v != "" { hasValue = true } @@ -57,32 +60,35 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri break } } + if prependLibId { + pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) + } return hash(pid) } - return func(mf model.MediaFile, md Metadata, spec string) string { + return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { switch spec { case "track_legacy": - return legacyTrackID(mf) + return legacyTrackID(mf, prependLibId) case "album_legacy": - return legacyAlbumID(md) + return legacyAlbumID(mf, md, prependLibId) } - return getPID(mf, md, spec) + return getPID(mf, md, spec, prependLibId) } } func (md Metadata) trackPID(mf model.MediaFile) string { - return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track) + return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true) } -func (md Metadata) albumID(mf model.MediaFile) string { - return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Album) +func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { + return createGetPID(id.NewHash)(mf, md, pidConf, true) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return createGetPID(id.NewHash)(mf, md, "albumartistid") + return createGetPID(id.NewHash)(mf, md, "albumartistid", false) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index d07b36331..7ae0c91f7 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -15,7 +15,7 @@ var _ = Describe("getPID", func() { md Metadata mf model.MediaFile sum hashFunc - getPID func(mf model.MediaFile, md Metadata, spec string) string + getPID getPIDFunc ) BeforeEach(func() { @@ -28,7 +28,7 @@ var _ = Describe("getPID", func() { When("no attributes were present", func() { It("should return empty pid", func() { md.tags = map[model.TagName][]string{} - pid := getPID(mf, md, spec) + pid := getPID(mf, md, spec, false) Expect(pid).To(Equal("()")) }) }) @@ -40,7 +40,7 @@ var _ = Describe("getPID", func() { "discnumber": {"1"}, "tracknumber": {"1"}, } - Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)")) }) }) When("only first field is present", func() { @@ -48,7 +48,7 @@ var _ = Describe("getPID", func() { md.tags = map[model.TagName][]string{ "musicbrainz_trackid": {"mbtrackid"}, } - Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)")) }) }) When("first is empty, but second field is present", func() { @@ -57,7 +57,7 @@ var _ = Describe("getPID", func() { "album": {"album name"}, "discnumber": {"1"}, } - Expect(getPID(mf, md, spec)).To(Equal("(album name\\1\\)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)")) }) }) }) @@ -73,7 +73,7 @@ var _ = Describe("getPID", func() { md.tags = map[model.TagName][]string{"title": {"title"}} md.filePath = "/path/to/file.mp3" mf.Title = "Title" - Expect(getPID(mf, md, spec)).To(Equal("(Title)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(Title)")) }) }) When("field is folder", func() { @@ -81,7 +81,7 @@ var _ = Describe("getPID", func() { spec := "folder|title" md.tags = map[model.TagName][]string{"title": {"title"}} mf.Path = "/path/to/file.mp3" - Expect(getPID(mf, md, spec)).To(Equal("(/path/to)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)")) }) }) When("field is albumid", func() { @@ -94,7 +94,7 @@ var _ = Describe("getPID", func() { "releasedate": {"2021-01-01"}, } mf.AlbumArtist = "Album Artist" - Expect(getPID(mf, md, spec)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))")) + Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))")) }) }) When("field is albumartistid", func() { @@ -104,14 +104,14 @@ var _ = Describe("getPID", func() { "albumartist": {"Album Artist"}, } mf.AlbumArtist = "Album Artist" - Expect(getPID(mf, md, spec)).To(Equal("((album artist))")) + Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))")) }) }) When("field is album", func() { It("should return the pid", func() { spec := "album|title" md.tags = map[model.TagName][]string{"album": {"Album Name"}} - Expect(getPID(mf, md, spec)).To(Equal("(album name)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(album name)")) }) }) }) @@ -123,7 +123,7 @@ var _ = Describe("getPID", func() { md.tags = map[model.TagName][]string{ "album": {"album name"}, } - Expect(getPID(mf, md, spec)).To(Equal("(album name)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(album name)")) }) }) When("the spec has spaces", func() { @@ -133,7 +133,7 @@ var _ = Describe("getPID", func() { "albumartist": {"Album Artist"}, "album": {"album name"}, } - Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)")) }) }) When("the spec has mixed case fields", func() { @@ -143,7 +143,129 @@ var _ = Describe("getPID", func() { "albumartist": {"Album Artist"}, "album": {"album name"}, } - Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)")) + }) + }) + }) + + Context("prependLibId functionality", func() { + BeforeEach(func() { + mf.LibraryID = 42 + }) + When("prependLibId is true", func() { + It("should prepend library ID to the hash input", func() { + spec := "album" + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + pid := getPID(mf, md, spec, true) + // The hash function should receive "42\test album" as input + Expect(pid).To(Equal("(42\\test album)")) + }) + }) + When("prependLibId is false", func() { + It("should not prepend library ID to the hash input", func() { + spec := "album" + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + pid := getPID(mf, md, spec, false) + // The hash function should receive "test album" as input + Expect(pid).To(Equal("(test album)")) + }) + }) + When("prependLibId is true with complex spec", func() { + It("should prepend library ID to the final hash input", func() { + spec := "musicbrainz_trackid|album,tracknumber" + md.tags = map[model.TagName][]string{ + "album": {"Test Album"}, + "tracknumber": {"1"}, + } + pid := getPID(mf, md, spec, true) + // Should use the fallback field and prepend library ID + Expect(pid).To(Equal("(42\\test album\\1)")) + }) + }) + When("prependLibId is true with nested albumid", func() { + It("should handle nested albumid calls correctly", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "album" + spec := "albumid" + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + mf.AlbumArtist = "Test Artist" + pid := getPID(mf, md, spec, true) + // The albumid call should also use prependLibId=true + Expect(pid).To(Equal("(42\\(42\\test album))")) + }) + }) + }) + + Context("legacy specs", func() { + Context("track_legacy", func() { + When("library ID is default (1)", func() { + It("should not prepend library ID even when prependLibId is true", func() { + mf.Path = "/path/to/track.mp3" + mf.LibraryID = 1 // Default library ID + // With default library, both should be the same + pidTrue := getPID(mf, md, "track_legacy", true) + pidFalse := getPID(mf, md, "track_legacy", false) + Expect(pidTrue).To(Equal(pidFalse)) + Expect(pidTrue).NotTo(BeEmpty()) + }) + }) + When("library ID is non-default", func() { + It("should prepend library ID when prependLibId is true", func() { + mf.Path = "/path/to/track.mp3" + mf.LibraryID = 2 // Non-default library ID + pidTrue := getPID(mf, md, "track_legacy", true) + pidFalse := getPID(mf, md, "track_legacy", false) + Expect(pidTrue).NotTo(Equal(pidFalse)) + Expect(pidTrue).NotTo(BeEmpty()) + Expect(pidFalse).NotTo(BeEmpty()) + }) + }) + When("library ID is non-default but prependLibId is false", func() { + It("should not prepend library ID", func() { + mf.Path = "/path/to/track.mp3" + mf.LibraryID = 3 + mf2 := mf + mf2.LibraryID = 1 // Default library + pidNonDefault := getPID(mf, md, "track_legacy", false) + pidDefault := getPID(mf2, md, "track_legacy", false) + // Should be the same since prependLibId=false + Expect(pidNonDefault).To(Equal(pidDefault)) + }) + }) + }) + Context("album_legacy", func() { + When("library ID is default (1)", func() { + It("should not prepend library ID even when prependLibId is true", func() { + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + mf.LibraryID = 1 // Default library ID + pidTrue := getPID(mf, md, "album_legacy", true) + pidFalse := getPID(mf, md, "album_legacy", false) + Expect(pidTrue).To(Equal(pidFalse)) + Expect(pidTrue).NotTo(BeEmpty()) + }) + }) + When("library ID is non-default", func() { + It("should prepend library ID when prependLibId is true", func() { + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + mf.LibraryID = 2 // Non-default library ID + pidTrue := getPID(mf, md, "album_legacy", true) + pidFalse := getPID(mf, md, "album_legacy", false) + Expect(pidTrue).NotTo(Equal(pidFalse)) + Expect(pidTrue).NotTo(BeEmpty()) + Expect(pidFalse).NotTo(BeEmpty()) + }) + }) + When("library ID is non-default but prependLibId is false", func() { + It("should not prepend library ID", func() { + md.tags = map[model.TagName][]string{"album": {"Test Album"}} + mf.LibraryID = 3 + mf2 := mf + mf2.LibraryID = 1 // Default library + pidNonDefault := getPID(mf, md, "album_legacy", false) + pidDefault := getPID(mf2, md, "album_legacy", false) + // Should be the same since prependLibId=false + Expect(pidNonDefault).To(Equal(pidDefault)) + }) }) }) }) diff --git a/model/playlist.go b/model/playlist.go index 40b666ff9..a87019ed5 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -40,6 +40,21 @@ func (pls Playlist) MediaFiles() MediaFiles { return pls.Tracks.MediaFiles() } +func (pls *Playlist) refreshStats() { + pls.SongCount = len(pls.Tracks) + pls.Duration = 0 + pls.Size = 0 + for _, t := range pls.Tracks { + pls.Duration += t.MediaFile.Duration + pls.Size += t.MediaFile.Size + } +} + +func (pls *Playlist) SetTracks(tracks PlaylistTracks) { + pls.Tracks = tracks + pls.refreshStats() +} + func (pls *Playlist) RemoveTracks(idxToRemove []int) { var newTracks PlaylistTracks for i, t := range pls.Tracks { @@ -49,6 +64,7 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) { newTracks = append(newTracks, t) } pls.Tracks = newTracks + pls.refreshStats() } // ToM3U8 exports the playlist to the Extended M3U8 format @@ -56,7 +72,7 @@ func (pls *Playlist) ToM3U8() string { return pls.MediaFiles().ToM3U8(pls.Name, true) } -func (pls *Playlist) AddTracks(mediaFileIds []string) { +func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) { pos := len(pls.Tracks) for _, mfId := range mediaFileIds { pos++ @@ -68,6 +84,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) { } pls.Tracks = append(pls.Tracks, t) } + pls.refreshStats() } func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { @@ -82,6 +99,7 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { } pls.Tracks = append(pls.Tracks, t) } + pls.refreshStats() } func (pls Playlist) CoverArtID() ArtworkID { diff --git a/model/searchable.go b/model/searchable.go index d37299997..cc4f0b44e 100644 --- a/model/searchable.go +++ b/model/searchable.go @@ -1,5 +1,5 @@ package model type SearchableRepository[T any] interface { - Search(q string, offset, size int, includeMissing bool) (T, error) + Search(q string, offset, size int, includeMissing bool, options ...QueryOptions) (T, error) } diff --git a/model/tag.go b/model/tag.go index a1f4e28da..8f9c60f37 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,7 +153,7 @@ func (t Tags) Add(name TagName, v string) { } type TagRepository interface { - Add(...Tag) error + Add(libraryID int, tags ...Tag) error UpdateCounts() error } diff --git a/model/user.go b/model/user.go index 7c41ac041..aabedc096 100644 --- a/model/user.go +++ b/model/user.go @@ -1,6 +1,8 @@ package model -import "time" +import ( + "time" +) type User struct { ID string `structs:"id" json:"id"` @@ -13,6 +15,9 @@ type User struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + // Library associations (many-to-many relationship) + Libraries Libraries `structs:"-" json:"libraries,omitempty"` + // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` // This is used to set or change a password when calling Put. If it is empty, the password is not changed. @@ -22,6 +27,18 @@ type User struct { CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` } +func (u User) HasLibraryAccess(libraryID int) bool { + if u.IsAdmin { + return true // Admin users have access to all libraries + } + for _, lib := range u.Libraries { + if lib.ID == libraryID { + return true + } + } + return false +} + type Users []User type UserRepository interface { @@ -35,4 +52,8 @@ type UserRepository interface { FindByUsername(username string) (*User, error) // FindByUsernameWithPassword is the same as above, but also returns the decrypted password FindByUsernameWithPassword(username string) (*User, error) + + // Library association methods + GetUserLibraries(userID string) (Libraries, error) + SetUserLibraries(userID string, libraryIDs []int) error } diff --git a/model/user_test.go b/model/user_test.go new file mode 100644 index 000000000..ab66a29a9 --- /dev/null +++ b/model/user_test.go @@ -0,0 +1,83 @@ +package model_test + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("User", func() { + var user model.User + var libraries model.Libraries + + BeforeEach(func() { + libraries = model.Libraries{ + {ID: 1, Name: "Rock Library", Path: "/music/rock"}, + {ID: 2, Name: "Jazz Library", Path: "/music/jazz"}, + {ID: 3, Name: "Classical Library", Path: "/music/classical"}, + } + + user = model.User{ + ID: "user1", + UserName: "testuser", + Name: "Test User", + Email: "test@example.com", + IsAdmin: false, + Libraries: libraries, + } + }) + + Describe("HasLibraryAccess", func() { + Context("when user is admin", func() { + BeforeEach(func() { + user.IsAdmin = true + }) + + It("returns true for any library ID", func() { + Expect(user.HasLibraryAccess(1)).To(BeTrue()) + Expect(user.HasLibraryAccess(99)).To(BeTrue()) + Expect(user.HasLibraryAccess(-1)).To(BeTrue()) + }) + + It("returns true even when user has no libraries assigned", func() { + user.Libraries = nil + Expect(user.HasLibraryAccess(1)).To(BeTrue()) + }) + }) + + Context("when user is not admin", func() { + BeforeEach(func() { + user.IsAdmin = false + }) + + It("returns true for libraries the user has access to", func() { + Expect(user.HasLibraryAccess(1)).To(BeTrue()) + Expect(user.HasLibraryAccess(2)).To(BeTrue()) + Expect(user.HasLibraryAccess(3)).To(BeTrue()) + }) + + It("returns false for libraries the user does not have access to", func() { + Expect(user.HasLibraryAccess(4)).To(BeFalse()) + Expect(user.HasLibraryAccess(99)).To(BeFalse()) + Expect(user.HasLibraryAccess(-1)).To(BeFalse()) + Expect(user.HasLibraryAccess(0)).To(BeFalse()) + }) + + It("returns false when user has no libraries assigned", func() { + user.Libraries = nil + Expect(user.HasLibraryAccess(1)).To(BeFalse()) + }) + + It("handles duplicate library IDs correctly", func() { + user.Libraries = model.Libraries{ + {ID: 1, Name: "Library 1", Path: "/music1"}, + {ID: 1, Name: "Library 1 Duplicate", Path: "/music1-dup"}, + {ID: 2, Name: "Library 2", Path: "/music2"}, + } + Expect(user.HasLibraryAccess(1)).To(BeTrue()) + Expect(user.HasLibraryAccess(2)).To(BeTrue()) + Expect(user.HasLibraryAccess(3)).To(BeFalse()) + }) + }) + }) +}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 08bc80039..682a409a1 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -123,6 +123,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { "missing": booleanFilter, "genre_id": tagIDFilter, "role_total_id": allRolesFilter, + "library_id": libraryIdFilter, } // Add all album tags as filters for tag := range model.AlbumLevelTags() { @@ -184,9 +185,10 @@ func allRolesFilter(_ string, value interface{}) Sqlizer { } func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sql := r.newSelect() - sql = r.withAnnotation(sql, "album.id") - return r.count(sql, options...) + query := r.newSelect() + query = r.withAnnotation(query, "album.id") + query = r.applyLibraryFilter(query) + return r.count(query, options...) } func (r *albumRepository) Exists(id string) (bool, error) { @@ -216,8 +218,10 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { } func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("album.*") - return r.withAnnotation(sql, "album.id") + sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). + LeftJoin("library on album.library_id = library.id") + sql = r.withAnnotation(sql, "album.id") + return r.applyLibraryFilter(sql) } func (r *albumRepository) Get(id string) (*model.Album, error) { @@ -291,7 +295,6 @@ func (r *albumRepository) TouchByMissingFolder() (int64, error) { // It does not need to load participants, as they are not used by the scanner. func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { query := r.selectAlbum(). - Join("library on library.id = album.library_id"). Where(And{ Eq{"library.id": libID}, ConcatExpr("album.imported_at > library.last_scan_at"), @@ -346,15 +349,15 @@ func (r *albumRepository) purgeEmpty() error { return nil } -func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) { +func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) { var res dbAlbums if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectAlbum(), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) if err != nil { return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") + err := r.doSearch(r.selectAlbum(options...), q, offset, size, includeMissing, &res, "name") if err != nil { return nil, fmt.Errorf("searching album by query %q: %w", q, err) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f5b892ba1..af95e0670 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -27,9 +27,9 @@ type artistRepository struct { } type dbArtist struct { - *model.Artist `structs:",flatten"` - SimilarArtists string `structs:"-" json:"-"` - Stats string `structs:"-" json:"-"` + *model.Artist `structs:",flatten"` + SimilarArtists string `structs:"-" json:"-"` + LibraryStatsJSON string `structs:"-" json:"-"` } type dbSimilarArtist struct { @@ -38,27 +38,45 @@ type dbSimilarArtist struct { } func (a *dbArtist) PostScan() error { - var stats map[string]map[string]int64 - if err := json.Unmarshal([]byte(a.Stats), &stats); err != nil { - return fmt.Errorf("parsing artist stats from db: %w", err) - } a.Artist.Stats = make(map[model.Role]model.ArtistStats) - for key, c := range stats { - if key == "total" { - a.Artist.Size = c["s"] - a.Artist.SongCount = int(c["m"]) - a.Artist.AlbumCount = int(c["a"]) + + if a.LibraryStatsJSON != "" { + var rawLibStats map[string]map[string]map[string]int64 + if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil { + return fmt.Errorf("parsing artist stats from db: %w", err) } - role := model.RoleFromString(key) - if role == model.RoleInvalid { - continue - } - a.Artist.Stats[role] = model.ArtistStats{ - SongCount: int(c["m"]), - AlbumCount: int(c["a"]), - Size: c["s"], + + for _, stats := range rawLibStats { + // Sum all libraries roles stats + for key, stat := range stats { + // Aggregate stats into the main Artist.Stats map + artistStats := model.ArtistStats{ + SongCount: int(stat["m"]), + AlbumCount: int(stat["a"]), + Size: stat["s"], + } + + // Store total stats into the main attributes + if key == "total" { + a.Artist.Size += artistStats.Size + a.Artist.SongCount += artistStats.SongCount + a.Artist.AlbumCount += artistStats.AlbumCount + } + + role := model.RoleFromString(key) + if role == model.RoleInvalid { + continue + } + + current := a.Artist.Stats[role] + current.Size += artistStats.Size + current.SongCount += artistStats.SongCount + current.AlbumCount += artistStats.AlbumCount + a.Artist.Stats[role] = current + } } } + a.Artist.SimilarArtists = nil if a.SimilarArtists == "" { return nil @@ -113,11 +131,12 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) r.tableName = "artist" // To be used by the idFilter below r.registerModel(&model.Artist{}, map[string]filterFunc{ - "id": idFilter(r.tableName), - "name": fullTextFilter(r.tableName, "mbz_artist_id"), - "starred": booleanFilter, - "role": roleFilter, - "missing": booleanFilter, + "id": idFilter(r.tableName), + "name": fullTextFilter(r.tableName, "mbz_artist_id"), + "starred": booleanFilter, + "role": roleFilter, + "missing": booleanFilter, + "library_id": artistLibraryIdFilter, }) r.setSortMappings(map[string]string{ "name": "order_artist_name", @@ -127,9 +146,9 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi "size": "stats->>'total'->>'s'", // Stats by credits that are currently available - "maincredit_song_count": "stats->>'maincredit'->>'m'", - "maincredit_album_count": "stats->>'maincredit'->>'a'", - "maincredit_size": "stats->>'maincredit'->>'a'", + "maincredit_song_count": "sum(stats->>'maincredit'->>'m')", + "maincredit_album_count": "sum(stats->>'maincredit'->>'a')", + "maincredit_size": "sum(stats->>'maincredit'->>'s')", }) return r } @@ -137,26 +156,58 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { if _, ok := model.AllRoles[role]; ok { - return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil} + return Expr("EXISTS (SELECT 1 FROM library_artist WHERE library_artist.artist_id = artist.id AND JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL)") } } return Eq{"1": 2} } -func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { - query := r.newSelect(options...).Columns("artist.*") - query = r.withAnnotation(query, "artist.id") +// artistLibraryIdFilter filters artists based on library access through the library_artist table +func artistLibraryIdFilter(_ string, value interface{}) Sqlizer { + return Eq{"library_artist.library_id": value} +} + +// applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table +func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder { + user := loggedUser(r.ctx) + if user.ID == invalidUserId { + // No user context - return empty result set + return query.Where(Eq{"1": "0"}) + } + + // Apply library filtering by joining only with accessible libraries + query = query.LeftJoin("library_artist on library_artist.artist_id = artist.id"). + Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) + return query } +func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { + // Stats Format: {"1": {"albumartist": {"songCount": 10, "albumCount": 5, "size": 1024}, "artist": {...}}, "2": {...}} + query := r.newSelect(options...).Columns("artist.*", + "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") + + query = r.applyLibraryFilterToArtistQuery(query) + query = query.GroupBy("artist.id") + return r.withAnnotation(query, "artist.id") +} + func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() + query = r.applyLibraryFilterToArtistQuery(query) query = r.withAnnotation(query, "artist.id") return r.count(query, options...) } +// Exists checks if an artist with the given ID exists in the database and is accessible by the current user. func (r *artistRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"artist.id": id}) + // Create a query using the same library filtering logic as selectArtist() + query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) + query = r.applyLibraryFilterToArtistQuery(query) + + var res struct{ Exist int64 } + err := r.queryOne(query, &res) + return res.Exist > 0, err } func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { @@ -213,8 +264,15 @@ func (r *artistRepository) getIndexKey(a model.Artist) string { return "#" } -// TODO Cache the index (recalculate when there are changes to the DB) -func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) { +// GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured. +// It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums). +// TODO Cache the index (recalculate at scan time) +func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { + // Validate library IDs. If no library IDs are provided, return an empty index. + if len(libraryIds) == 0 { + return nil, nil + } + options := model.QueryOptions{Sort: "name"} if len(roles) > 0 { roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { @@ -229,10 +287,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m options.Filters = And{options.Filters, Eq{"artist.missing": false}} } } + + libFilter := artistLibraryIdFilter("library_id", libraryIds) + if options.Filters == nil { + options.Filters = libFilter + } else { + options.Filters = And{options.Filters, libFilter} + } + artists, err := r.GetAll(options) if err != nil { return nil, err } + var result model.ArtistIndexes for k, v := range slice.Group(artists, r.getIndexKey) { result = append(result, model.ArtistIndex{ID: k, Artists: v}) @@ -299,6 +366,7 @@ on conflict (user_id, item_id, item_type) do update // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. // When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. +// This method now calculates per-library statistics and stores them in the library_artist junction table. func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { var allTouchedArtistIDs []string if allArtists { @@ -327,9 +395,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { } // Template for the batch update with placeholder markers that we'll replace + // This now calculates per-library statistics and stores them in library_artist.stats batchUpdateStatsSQL := ` WITH artist_role_counters AS ( SELECT jt.atom AS artist_id, + mf.library_id, substr( replace(jt.path, '$.', ''), 1, @@ -344,10 +414,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { FROM media_file mf JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY jt.atom, role + GROUP BY jt.atom, mf.library_id, role ), artist_total_counters AS ( SELECT mfa.artist_id, + mf.library_id, 'total' AS role, count(DISTINCT mf.album_id) AS album_count, count(DISTINCT mf.id) AS count, @@ -355,40 +426,43 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY mfa.artist_id + GROUP BY mfa.artist_id, mf.library_id ), artist_participant_counter AS ( SELECT mfa.artist_id, - 'maincredit' AS role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size + mf.library_id, + 'maincredit' AS role, + count(DISTINCT mf.album_id) AS album_count, + count(DISTINCT mf.id) AS count, + sum(mf.size) AS size FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders AND mfa.role IN ('albumartist', 'artist') - GROUP BY mfa.artist_id + GROUP BY mfa.artist_id, mf.library_id ), combined_counters AS ( - SELECT artist_id, role, album_count, count, size FROM artist_role_counters + SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters UNION - SELECT artist_id, role, album_count, count, size FROM artist_total_counters + SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters UNION - SELECT artist_id, role, album_count, count, size FROM artist_participant_counter + SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter ), - artist_counters AS ( - SELECT artist_id AS id, + library_artist_counters AS ( + SELECT artist_id, + library_id, json_group_object( replace(role, '"', ''), json_object('a', album_count, 'm', count, 's', size) ) AS counters FROM combined_counters - GROUP BY artist_id + GROUP BY artist_id, library_id ) - UPDATE artist - SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), - updated_at = datetime(current_timestamp, 'localtime') - WHERE artist.id IN (ROLE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders + UPDATE library_artist + SET stats = coalesce((SELECT counters FROM library_artist_counters lac + WHERE lac.artist_id = library_artist.artist_id + AND lac.library_id = library_artist.library_id), '{}') + WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders var totalRowsAffected int64 = 0 const batchSize = 1000 @@ -433,15 +507,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { return totalRowsAffected, nil } -func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) { +func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) { var res dbArtists if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectArtist(), q, []string{"mbz_artist_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, includeMissing, &res) if err != nil { return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &res, "json_extract(stats, '$.total.m') desc", "name") + err := r.doSearch(r.selectArtist(options...), q, offset, size, includeMissing, &res, + "sum(json_extract(stats, '$.total.m')) desc", "name") if err != nil { return nil, fmt.Errorf("searching artist by query %q: %w", q, err) } @@ -464,9 +539,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, e role = v } } - r.sortMappings["song_count"] = "stats->>'" + role + "'->>'m'" - r.sortMappings["album_count"] = "stats->>'" + role + "'->>'a'" - r.sortMappings["size"] = "stats->>'" + role + "'->>'s'" + r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" + r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" + r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')" return r.GetAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 0dc0b087c..2e19892a1 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -16,287 +17,571 @@ import ( ) var _ = Describe("ArtistRepository", func() { - var repo model.ArtistRepository - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - repo = NewArtistRepository(ctx, GetDBXBuilder()) - }) + Context("Core Functionality", func() { + Describe("GetIndexKey", func() { + // Note: OrderArtistName should never be empty, so we don't need to test for that + r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)} - Describe("Count", func() { - It("returns the number of artists in the DB", func() { - Expect(repo.CountAll()).To(Equal(int64(2))) + DescribeTable("returns correct index key based on PreferSortTags setting", + func(preferSortTags bool, sortArtistName, orderArtistName, expectedKey string) { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PreferSortTags = preferSortTags + a := model.Artist{SortArtistName: sortArtistName, OrderArtistName: orderArtistName, Name: "Test"} + idx := GetIndexKey(&r, a) + Expect(idx).To(Equal(expectedKey)) + }, + Entry("PreferSortTags=false, SortArtistName empty -> uses OrderArtistName", false, "", "Bar", "B"), + Entry("PreferSortTags=false, SortArtistName not empty -> still uses OrderArtistName", false, "Foo", "Bar", "B"), + Entry("PreferSortTags=true, SortArtistName not empty -> uses SortArtistName", true, "Foo", "Bar", "F"), + Entry("PreferSortTags=true, SortArtistName empty -> falls back to OrderArtistName", true, "", "Bar", "B"), + ) }) - }) - Describe("Exists", func() { - It("returns true for an artist that is in the DB", func() { - Expect(repo.Exists("3")).To(BeTrue()) - }) - It("returns false for an artist that is in the DB", func() { - Expect(repo.Exists("666")).To(BeFalse()) - }) - }) + Describe("roleFilter", func() { + DescribeTable("validates roles and returns appropriate SQL expressions", + func(role string, shouldBeValid bool) { + result := roleFilter("", role) + if shouldBeValid { + expectedExpr := squirrel.Expr("EXISTS (SELECT 1 FROM library_artist WHERE library_artist.artist_id = artist.id AND JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL)") + Expect(result).To(Equal(expectedExpr)) + } else { + expectedInvalid := squirrel.Eq{"1": 2} + Expect(result).To(Equal(expectedInvalid)) + } + }, + // Valid roles from model.AllRoles + Entry("artist role", "artist", true), + Entry("albumartist role", "albumartist", true), + Entry("composer role", "composer", true), + Entry("conductor role", "conductor", true), + Entry("lyricist role", "lyricist", true), + Entry("arranger role", "arranger", true), + Entry("producer role", "producer", true), + Entry("director role", "director", true), + Entry("engineer role", "engineer", true), + Entry("mixer role", "mixer", true), + Entry("remixer role", "remixer", true), + Entry("djmixer role", "djmixer", true), + Entry("performer role", "performer", true), + Entry("maincredit role", "maincredit", true), + // Invalid roles + Entry("invalid role - wizard", "wizard", false), + Entry("invalid role - songanddanceman", "songanddanceman", false), + Entry("empty string", "", false), + Entry("SQL injection attempt", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--", false), + ) - Describe("Get", func() { - It("saves and retrieves data", func() { - artist, err := repo.Get("2") - Expect(err).ToNot(HaveOccurred()) - Expect(artist.Name).To(Equal(artistKraftwerk.Name)) + It("handles non-string input types", func() { + expectedInvalid := squirrel.Eq{"1": 2} + Expect(roleFilter("", 123)).To(Equal(expectedInvalid)) + Expect(roleFilter("", nil)).To(Equal(expectedInvalid)) + Expect(roleFilter("", []string{"artist"})).To(Equal(expectedInvalid)) + }) }) - }) - Describe("GetIndexKey", func() { - // Note: OrderArtistName should never be empty, so we don't need to test for that - r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)} - When("PreferSortTags is false", func() { + Describe("dbArtist mapping", func() { + var ( + artist *model.Artist + dba *dbArtist + ) + BeforeEach(func() { - conf.Server.PreferSortTags = false + artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"} + dba = &dbArtist{Artist: artist} }) - It("returns the OrderArtistName key is SortArtistName is empty", func() { - conf.Server.PreferSortTags = false - a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"} - idx := GetIndexKey(&r, a) - Expect(idx).To(Equal("B")) + + Describe("PostScan", func() { + It("parses stats and similar artists correctly", func() { + stats := map[string]map[string]map[string]int64{ + "1": { + "total": {"s": 1000, "m": 10, "a": 2}, + "composer": {"s": 500, "m": 5, "a": 1}, + }, + } + statsJSON, _ := json.Marshal(stats) + dba.LibraryStatsJSON = string(statsJSON) + dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]` + + err := dba.PostScan() + Expect(err).ToNot(HaveOccurred()) + Expect(dba.Artist.Size).To(Equal(int64(1000))) + Expect(dba.Artist.SongCount).To(Equal(10)) + Expect(dba.Artist.AlbumCount).To(Equal(2)) + Expect(dba.Artist.Stats).To(HaveLen(1)) + Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500))) + Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5)) + Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1)) + Expect(dba.Artist.SimilarArtists).To(HaveLen(2)) + Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2")) + Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC")) + Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty()) + Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars")) + }) }) - It("returns the OrderArtistName key even if SortArtistName is not empty", func() { - a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"} - idx := GetIndexKey(&r, a) - Expect(idx).To(Equal("B")) - }) - }) - When("PreferSortTags is true", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - It("returns the SortArtistName key if it is not empty", func() { - a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"} - idx := GetIndexKey(&r, a) - Expect(idx).To(Equal("F")) - }) - It("returns the OrderArtistName key if SortArtistName is empty", func() { - a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"} - idx := GetIndexKey(&r, a) - Expect(idx).To(Equal("B")) + + Describe("PostMapArgs", func() { + It("maps empty similar artists correctly", func() { + m := make(map[string]any) + err := dba.PostMapArgs(m) + Expect(err).ToNot(HaveOccurred()) + Expect(m).To(HaveKeyWithValue("similar_artists", "[]")) + }) + + It("maps similar artists and full text correctly", func() { + artist.SimilarArtists = []model.Artist{ + {ID: "2", Name: "AC/DC"}, + {Name: "Test;With:Sep,Chars"}, + } + m := make(map[string]any) + err := dba.PostMapArgs(m) + Expect(err).ToNot(HaveOccurred()) + Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`)) + Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van")) + }) + + It("does not override empty sort_artist_name and mbz_artist_id", func() { + m := map[string]any{ + "sort_artist_name": "", + "mbz_artist_id": "", + } + err := dba.PostMapArgs(m) + Expect(err).ToNot(HaveOccurred()) + Expect(m).ToNot(HaveKey("sort_artist_name")) + Expect(m).ToNot(HaveKey("mbz_artist_id")) + }) }) }) }) - Describe("GetIndex", func() { - When("PreferSortTags is true", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() { - // Set SortArtistName to "Foo" for Beatles - artistBeatles.SortArtistName = "Foo" - er := repo.Put(&artistBeatles) - Expect(er).To(BeNil()) + Context("Admin User Operations", func() { + var repo model.ArtistRepository - idx, err := repo.GetIndex(false) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) - Expect(idx[0].ID).To(Equal("F")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()) + }) - // Restore the original value - artistBeatles.SortArtistName = "" - er = repo.Put(&artistBeatles) - Expect(er).To(BeNil()) + Describe("Basic Operations", func() { + Describe("Count", func() { + It("returns the number of artists in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) }) - // BFR Empty SortArtistName is not saved in the DB anymore - XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { - idx, err := repo.GetIndex(false) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + Describe("Exists", func() { + It("returns true for an artist that is in the DB", func() { + Expect(repo.Exists("3")).To(BeTrue()) + }) + It("returns false for an artist that is NOT in the DB", func() { + Expect(repo.Exists("666")).To(BeFalse()) + }) + }) + + Describe("Get", func() { + It("retrieves existing artist data", func() { + artist, err := repo.Get("2") + Expect(err).ToNot(HaveOccurred()) + Expect(artist.Name).To(Equal(artistKraftwerk.Name)) + }) }) }) - When("PreferSortTags is false", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = false - }) - It("returns the index when SortArtistName is NOT empty", func() { - // Set SortArtistName to "Foo" for Beatles - artistBeatles.SortArtistName = "Foo" - er := repo.Put(&artistBeatles) - Expect(er).To(BeNil()) + Describe("GetIndex", func() { + When("PreferSortTags is true", func() { + BeforeEach(func() { + conf.Server.PreferSortTags = true + }) + It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() { + // Set SortArtistName to "Foo" for Beatles + artistBeatles.SortArtistName = "Foo" + er := repo.Put(&artistBeatles) + Expect(er).To(BeNil()) - idx, err := repo.GetIndex(false) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + Expect(idx[0].ID).To(Equal("F")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + Expect(idx[1].ID).To(Equal("K")) + Expect(idx[1].Artists).To(HaveLen(1)) + Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) - // Restore the original value - artistBeatles.SortArtistName = "" - er = repo.Put(&artistBeatles) - Expect(er).To(BeNil()) + // Restore the original value + artistBeatles.SortArtistName = "" + er = repo.Put(&artistBeatles) + Expect(er).To(BeNil()) + }) + + // BFR Empty SortArtistName is not saved in the DB anymore + XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + Expect(idx[0].ID).To(Equal("B")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + Expect(idx[1].ID).To(Equal("K")) + Expect(idx[1].Artists).To(HaveLen(1)) + Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + }) }) - It("returns the index when SortArtistName is empty", func() { - idx, err := repo.GetIndex(false) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + When("PreferSortTags is false", func() { + BeforeEach(func() { + conf.Server.PreferSortTags = false + }) + It("returns the index when SortArtistName is NOT empty", func() { + // Set SortArtistName to "Foo" for Beatles + artistBeatles.SortArtistName = "Foo" + er := repo.Put(&artistBeatles) + Expect(er).To(BeNil()) + + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + Expect(idx[0].ID).To(Equal("B")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + Expect(idx[1].ID).To(Equal("K")) + Expect(idx[1].Artists).To(HaveLen(1)) + Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + + // Restore the original value + artistBeatles.SortArtistName = "" + er = repo.Put(&artistBeatles) + Expect(er).To(BeNil()) + }) + + It("returns the index when SortArtistName is empty", func() { + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + Expect(idx[0].ID).To(Equal("B")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + Expect(idx[1].ID).To(Equal("K")) + Expect(idx[1].Artists).To(HaveLen(1)) + Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) + }) + }) + + When("filtering by role", func() { + var raw *artistRepository + + BeforeEach(func() { + raw = repo.(*artistRepository) + // Add stats to library_artist table since stats are now stored per-library + composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}` + producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` + + // Set Beatles as composer in library 1 + _, err := raw.executeSQL(squirrel.Insert("library_artist"). + Columns("library_id", "artist_id", "stats"). + Values(1, artistBeatles.ID, composerStats). + Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) + Expect(err).ToNot(HaveOccurred()) + + // Set Kraftwerk as producer in library 1 + _, err = raw.executeSQL(squirrel.Insert("library_artist"). + Columns("library_id", "artist_id", "stats"). + Values(1, artistKraftwerk.ID, producerStats). + Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up stats from library_artist table + _, _ = raw.executeSQL(squirrel.Update("library_artist"). + Set("stats", "{}"). + Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1})) + _, _ = raw.executeSQL(squirrel.Update("library_artist"). + Set("stats", "{}"). + Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1})) + }) + + It("returns only artists with the specified role", func() { + idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(1)) + Expect(idx[0].ID).To(Equal("B")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + }) + + It("returns artists with any of the specified roles", func() { + idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer, model.RoleProducer) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + + // Find Beatles and Kraftwerk in the results + var beatlesFound, kraftwerkFound bool + for _, index := range idx { + for _, artist := range index.Artists { + if artist.Name == artistBeatles.Name { + beatlesFound = true + } + if artist.Name == artistKraftwerk.Name { + kraftwerkFound = true + } + } + } + Expect(beatlesFound).To(BeTrue()) + Expect(kraftwerkFound).To(BeTrue()) + }) + + It("returns empty index when no artists have the specified role", func() { + idx, err := repo.GetIndex(false, []int{1}, model.RoleDirector) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) + }) + }) + + When("validating library IDs", func() { + It("returns nil when no library IDs are provided", func() { + idx, err := repo.GetIndex(false, []int{}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(BeNil()) + }) + + It("returns artists when library IDs are provided (admin user sees all content)", func() { + // Admin users can see all content when valid library IDs are provided + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + + // With non-existent library ID, admin users see no content because no artists are associated with that library + idx, err = repo.GetIndex(false, []int{999}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations + }) }) }) - When("filtering by role", func() { + Describe("MBID Search", func() { + var artistWithMBID model.Artist var raw *artistRepository BeforeEach(func() { raw = repo.(*artistRepository) - // Add stats to artists using direct SQL since Put doesn't populate stats - composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}` - producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` + // Create a test artist with MBID + artistWithMBID = model.Artist{ + ID: "test-mbid-artist", + Name: "Test MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440010", // Valid UUID v4 + } - // Set Beatles as composer - _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", composerStats).Where(squirrel.Eq{"id": artistBeatles.ID})) - Expect(err).ToNot(HaveOccurred()) - - // Set Kraftwerk as producer - _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", producerStats).Where(squirrel.Eq{"id": artistKraftwerk.ID})) + // Insert the test artist into the database with proper library association + err := createArtistWithLibrary(repo, &artistWithMBID, 1) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { - // Clean up stats - _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistBeatles.ID})) - _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistKraftwerk.ID})) + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) }) - It("returns only artists with the specified role", func() { - idx, err := repo.GetIndex(false, model.RoleComposer) + It("finds artist by mbz_artist_id", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(1)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-artist")) + Expect(results[0].Name).To(Equal("Test MBID Artist")) }) - It("returns artists with any of the specified roles", func() { - idx, err := repo.GetIndex(false, model.RoleComposer, model.RoleProducer) + It("returns empty result when MBID is not found", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles includeMissing parameter for MBID search", func() { + // Create a missing artist with MBID + missingArtist := model.Artist{ + ID: "test-missing-mbid-artist", + Name: "Test Missing MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440012", + Missing: true, + } + + err := createArtistWithLibrary(repo, &missingArtist, 1) + Expect(err).ToNot(HaveOccurred()) + + // Should not find missing artist when includeMissing is false + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Should find missing artist when includeMissing is true + results, err = repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + }) + }) + + Describe("Admin User Library Access", func() { + It("sees all artists regardless of library permissions", func() { + count, err := repo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) + + artists, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(2)) + + exists, err := repo.Exists(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) + }) + + Context("Regular User Operations", func() { + var restrictedRepo model.ArtistRepository + var unauthorizedUser model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Create a user without access to any libraries + unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false} + + // Create repository context for the unauthorized user + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + }) + + Describe("Library Access Restrictions", func() { + It("CountAll returns 0 for users without library access", func() { + count, err := restrictedRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(0))) + }) + + It("GetAll returns empty list for users without library access", func() { + artists, err := restrictedRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(BeEmpty()) + }) + + It("Exists returns false for existing artists when user has no library access", func() { + // These artists exist in the DB but the user has no access to them + exists, err := restrictedRepo.Exists(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + + exists, err = restrictedRepo.Exists(artistKraftwerk.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("Get returns ErrNotFound for existing artists when user has no library access", func() { + _, err := restrictedRepo.Get(artistBeatles.ID) + Expect(err).To(Equal(model.ErrNotFound)) + + _, err = restrictedRepo.Get(artistKraftwerk.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("Search returns empty results for users without library access", func() { + results, err := restrictedRepo.Search("Beatles", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + results, err = restrictedRepo.Search("Kraftwerk", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("GetIndex returns empty index for users without library access", func() { + idx, err := restrictedRepo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) + }) + }) + + Context("when user gains library access", func() { + BeforeEach(func() { + // Give the user access to library 1 + ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + + // First create the user if not exists + err := ur.Put(&unauthorizedUser) + Expect(err).ToNot(HaveOccurred()) + + // Then add library access + err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1}) + Expect(err).ToNot(HaveOccurred()) + + // Update the user object with the libraries to simulate middleware behavior + libraries, err := ur.GetUserLibraries(unauthorizedUser.ID) + Expect(err).ToNot(HaveOccurred()) + unauthorizedUser.Libraries = libraries + + // Recreate repository context with updated user + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + }) + + AfterEach(func() { + // Clean up: remove the user's library access + ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{}) + }) + + It("CountAll returns correct count after gaining access", func() { + count, err := restrictedRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk + }) + + It("GetAll returns artists after gaining access", func() { + artists, err := restrictedRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(2)) + + var names []string + for _, artist := range artists { + names = append(names, artist.Name) + } + Expect(names).To(ContainElements("The Beatles", "Kraftwerk")) + }) + + It("Exists returns true for accessible artists", func() { + exists, err := restrictedRepo.Exists(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = restrictedRepo.Exists(artistKraftwerk.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("GetIndex returns artists with proper library filtering", func() { + // With valid library access, should see artists + idx, err := restrictedRepo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(2)) - // Find Beatles and Kraftwerk in the results - var beatlesFound, kraftwerkFound bool - for _, index := range idx { - for _, artist := range index.Artists { - if artist.Name == artistBeatles.Name { - beatlesFound = true - } - if artist.Name == artistKraftwerk.Name { - kraftwerkFound = true - } - } - } - Expect(beatlesFound).To(BeTrue()) - Expect(kraftwerkFound).To(BeTrue()) - }) - - It("returns empty index when no artists have the specified role", func() { - idx, err := repo.GetIndex(false, model.RoleDirector) + // With non-existent library ID, should see nothing (non-admin user) + idx, err = restrictedRepo.GetIndex(false, []int{999}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) }) }) - Describe("dbArtist mapping", func() { - var ( - artist *model.Artist - dba *dbArtist - ) - - BeforeEach(func() { - artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"} - dba = &dbArtist{Artist: artist} - }) - - Describe("PostScan", func() { - It("parses stats and similar artists correctly", func() { - stats := map[string]map[string]int64{ - "total": {"s": 1000, "m": 10, "a": 2}, - "composer": {"s": 500, "m": 5, "a": 1}, - } - statsJSON, _ := json.Marshal(stats) - dba.Stats = string(statsJSON) - dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]` - - err := dba.PostScan() - Expect(err).ToNot(HaveOccurred()) - Expect(dba.Artist.Size).To(Equal(int64(1000))) - Expect(dba.Artist.SongCount).To(Equal(10)) - Expect(dba.Artist.AlbumCount).To(Equal(2)) - Expect(dba.Artist.Stats).To(HaveLen(1)) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500))) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5)) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1)) - Expect(dba.Artist.SimilarArtists).To(HaveLen(2)) - Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2")) - Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC")) - Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty()) - Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars")) - }) - }) - - Describe("PostMapArgs", func() { - It("maps empty similar artists correctly", func() { - m := make(map[string]any) - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).To(HaveKeyWithValue("similar_artists", "[]")) - }) - - It("maps similar artists and full text correctly", func() { - artist.SimilarArtists = []model.Artist{ - {ID: "2", Name: "AC/DC"}, - {Name: "Test;With:Sep,Chars"}, - } - m := make(map[string]any) - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`)) - Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van")) - }) - - It("does not override empty sort_artist_name and mbz_artist_id", func() { - m := map[string]any{ - "sort_artist_name": "", - "mbz_artist_id": "", - } - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).ToNot(HaveKey("sort_artist_name")) - Expect(m).ToNot(HaveKey("mbz_artist_id")) - }) - }) - - Describe("Missing artist visibility", func() { + Context("Permission-Based Behavior Comparison", func() { + Describe("Missing Artist Visibility", func() { + var repo model.ArtistRepository var raw *artistRepository var missing model.Artist @@ -306,6 +591,45 @@ var _ = Describe("ArtistRepository", func() { raw = repo.(*artistRepository) _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID})) Expect(err).ToNot(HaveOccurred()) + + // Add missing artist to library 1 so it can be found by library filtering + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + err = lr.AddArtist(1, missing.ID) + Expect(err).ToNot(HaveOccurred()) + + // Ensure the test user exists and has library access + ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + currentUser, ok := request.UserFrom(repo.(*artistRepository).ctx) + if ok { + // Create the user if it doesn't exist with default values if missing + testUser := model.User{ + ID: currentUser.ID, + UserName: currentUser.UserName, + Name: currentUser.Name, + Email: currentUser.Email, + IsAdmin: currentUser.IsAdmin, + } + // Provide defaults for missing fields + if testUser.UserName == "" { + testUser.UserName = testUser.ID + } + if testUser.Name == "" { + testUser.Name = testUser.ID + } + if testUser.Email == "" { + testUser.Email = testUser.ID + "@test.com" + } + + // Try to put the user (will fail silently if already exists) + _ = ur.Put(&testUser) + + // Add library association using SetUserLibraries + err = ur.SetUserLibraries(currentUser.ID, []int{1}) + // Ignore error if user already has these libraries or other conflicts + if err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") && !strings.Contains(err.Error(), "duplicate key") { + Expect(err).ToNot(HaveOccurred()) + } + } } removeMissing := func() { @@ -316,8 +640,17 @@ var _ = Describe("ArtistRepository", func() { Context("regular user", func() { BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Create user with library access (simulating middleware behavior) + regularUserWithLibs := model.User{ + ID: "u1", + IsAdmin: false, + Libraries: model.Libraries{ + {ID: 1, Name: "Test Library", Path: "/test"}, + }, + } ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "u1"}) + ctx = request.WithUser(ctx, regularUserWithLibs) repo = NewArtistRepository(ctx, GetDBXBuilder()) insertMissing() }) @@ -337,7 +670,7 @@ var _ = Describe("ArtistRepository", func() { }) It("does not return missing artist in GetIndex", func() { - idx, err := repo.GetIndex(false) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) // Only 2 artists should be present total := 0 @@ -350,6 +683,7 @@ var _ = Describe("ArtistRepository", func() { Context("admin user", func() { BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true}) repo = NewArtistRepository(ctx, GetDBXBuilder()) @@ -371,7 +705,7 @@ var _ = Describe("ArtistRepository", func() { }) It("returns missing artist in GetIndex when included", func() { - idx, err := repo.GetIndex(true) + idx, err := repo.GetIndex(true, []int{1}) Expect(err).ToNot(HaveOccurred()) total := 0 for _, ix := range idx { @@ -381,92 +715,166 @@ var _ = Describe("ArtistRepository", func() { }) }) }) - }) - Describe("roleFilter", func() { - It("filters out roles not present in the participants model", func() { - Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil})) - Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil})) - Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil})) - Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil})) - Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil})) - Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil})) - Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil})) - Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil})) - Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil})) - Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil})) - Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil})) - Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil})) - Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil})) + Describe("Library Filtering", func() { + var restrictedUser model.User + var restrictedRepo model.ArtistRepository + var adminRepo model.ArtistRepository + var lib2 model.Library - Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2})) - Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2})) - Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2})) - }) - }) + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) - Context("MBID Search", func() { - var artistWithMBID model.Artist - var raw *artistRepository + // Set up admin repo + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + adminRepo = NewArtistRepository(ctx, GetDBXBuilder()) - BeforeEach(func() { - raw = repo.(*artistRepository) - // Create a test artist with MBID - artistWithMBID = model.Artist{ - ID: "test-mbid-artist", - Name: "Test MBID Artist", - MbzArtistID: "550e8400-e29b-41d4-a716-446655440010", // Valid UUID v4 - } + // Create library for testing access restrictions + lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + err := lr.Put(&lib2) + Expect(err).ToNot(HaveOccurred()) - // Insert the test artist into the database - err := repo.Put(&artistWithMBID) - Expect(err).ToNot(HaveOccurred()) - }) + // Create a user with access to only library 1 + restrictedUser = model.User{ + ID: "search_user", + IsAdmin: false, + Libraries: model.Libraries{ + {ID: 1, Name: "Library 1", Path: "/lib1"}, + }, + } - AfterEach(func() { - // Clean up test data using direct SQL - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) - }) + // Create repository context for the restricted user + ctx = log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) - It("finds artist by mbz_artist_id", func() { - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-mbid-artist")) - Expect(results[0].Name).To(Equal("Test MBID Artist")) - }) + // Ensure both test artists are associated with library 1 + err = lr.AddArtist(1, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + err = lr.AddArtist(1, artistKraftwerk.ID) + Expect(err).ToNot(HaveOccurred()) - It("returns empty result when MBID is not found", func() { - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) + // Create the restricted user in the database + ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + err = ur.Put(&restrictedUser) + Expect(err).ToNot(HaveOccurred()) + err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) + Expect(err).ToNot(HaveOccurred()) + }) - It("handles includeMissing parameter for MBID search", func() { - // Create a missing artist with MBID - missingArtist := model.Artist{ - ID: "test-missing-mbid-artist", - Name: "Test Missing MBID Artist", - MbzArtistID: "550e8400-e29b-41d4-a716-446655440012", - Missing: true, - } + AfterEach(func() { + // Clean up library 2 + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) + }) - err := repo.Put(&missingArtist) - Expect(err).ToNot(HaveOccurred()) + Context("MBID Search", func() { + var artistWithMBID model.Artist - // Should not find missing artist when includeMissing is false - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) + BeforeEach(func() { + artistWithMBID = model.Artist{ + ID: "search-mbid-artist", + Name: "Search MBID Artist", + MbzArtistID: "f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", + } + err := createArtistWithLibrary(adminRepo, &artistWithMBID, 1) + Expect(err).ToNot(HaveOccurred()) + }) - // Should find missing artist when includeMissing is true - results, err = repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, true) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) + AfterEach(func() { + raw := adminRepo.(*artistRepository) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + }) - // Clean up - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + It("allows admin to find artist by MBID regardless of library", func() { + results, err := adminRepo.Search("f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("search-mbid-artist")) + }) + + It("allows restricted user to find artist by MBID when in accessible library", func() { + results, err := restrictedRepo.Search("f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("search-mbid-artist")) + }) + + It("prevents restricted user from finding artist by MBID when not in accessible library", func() { + // Create an artist in library 2 (not accessible to restricted user) + inaccessibleArtist := model.Artist{ + ID: "inaccessible-mbid-artist", + Name: "Inaccessible MBID Artist", + MbzArtistID: "a74b1b7f-71a5-4011-9441-d0b5e4122711", + } + err := adminRepo.Put(&inaccessibleArtist) + Expect(err).ToNot(HaveOccurred()) + + // Add to library 2 (not accessible to restricted user) + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Clean up + raw := adminRepo.(*artistRepository) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + }) + }) + + Context("Text Search", func() { + It("allows admin to find artists by name regardless of library", func() { + results, err := adminRepo.Search("Beatles", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("The Beatles")) + }) + + It("correctly prevents restricted user from finding artists by name when not in accessible library", func() { + // Create an artist in library 2 (not accessible to restricted user) + inaccessibleArtist := model.Artist{ + ID: "inaccessible-text-artist", + Name: "Unique Search Name Artist", + } + err := adminRepo.Put(&inaccessibleArtist) + Expect(err).ToNot(HaveOccurred()) + + // Add to library 2 (not accessible to restricted user) + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("Unique Search Name", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + + // Text search correctly respects library filtering + Expect(results).To(BeEmpty(), "Text search should respect library filtering") + + // Clean up + raw := adminRepo.(*artistRepository) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + }) + }) }) }) }) + +// Helper function to create an artist with proper library association. +// This ensures test artists always have library_artist associations to avoid orphaned artists in tests. +func createArtistWithLibrary(repo model.ArtistRepository, artist *model.Artist, libraryID int) error { + err := repo.Put(artist) + if err != nil { + return err + } + + // Add the artist to the specified library + lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + return lr.AddArtist(libraryID, artist.ID) +} diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 02b272134..96a9bae82 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -61,8 +61,9 @@ func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderReposi } func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Columns("folder.*", "library.path as library_path"). + sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path"). Join("library on library.id = folder.library_id") + return r.applyLibraryFilter(sql) } func (r folderRepository) Get(id string) (*model.Folder, error) { @@ -85,8 +86,9 @@ func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, err } func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { - sq := r.newSelect(opt...).Columns("count(*)") - return r.count(sq) + query := r.newSelect(opt...).Columns("count(*)") + query = r.applyLibraryFilter(query) + return r.count(query) } func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) { diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index e92e1491a..311eb0a68 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -10,31 +10,18 @@ import ( ) type genreRepository struct { - sqlRepository + *baseTagRepository } func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { - r := &genreRepository{} - r.ctx = ctx - r.db = db - r.registerModel(&model.Tag{}, map[string]filterFunc{ - "name": containsFilter("tag_value"), - }) - r.setSortMappings(map[string]string{ - "name": "tag_name", - }) - return r + genreFilter := model.TagGenre + return &genreRepository{ + baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + } } func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { - return r.newSelect(opt...). - Columns( - "id", - "tag_value as name", - "album_count", - "media_file_count as song_count", - ). - Where(Eq{"tag.tag_name": model.TagGenre}) + return r.newSelect(opt...) } func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { @@ -44,12 +31,10 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error return res, err } -func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.count(r.selectGenre(), r.parseRestOptions(r.ctx, options...)) -} +// Override ResourceRepository methods to return Genre objects instead of Tag objects func (r *genreRepository) Read(id string) (interface{}, error) { - sel := r.selectGenre().Columns("*").Where(Eq{"id": id}) + sel := r.selectGenre().Where(Eq{"tag.id": id}) var res model.Genre err := r.queryOne(sel, &res) return &res, err @@ -59,10 +44,6 @@ func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, er return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *genreRepository) EntityName() string { - return r.tableName -} - func (r *genreRepository) NewInstance() interface{} { return &model.Genre{} } diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go new file mode 100644 index 000000000..e7b43689c --- /dev/null +++ b/persistence/genre_repository_test.go @@ -0,0 +1,256 @@ +package persistence + +import ( + "context" + "slices" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GenreRepository", func() { + var repo model.GenreRepository + var restRepo model.ResourceRepository + var tagRepo model.TagRepository + var ctx context.Context + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + genreRepo := NewGenreRepository(ctx, GetDBXBuilder()) + repo = genreRepo + restRepo = genreRepo.(model.ResourceRepository) + tagRepo = NewTagRepository(ctx, GetDBXBuilder()) + + // Clear any existing tags to ensure test isolation + db := GetDBXBuilder() + _, err := db.NewQuery("DELETE FROM tag").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Ensure library 1 exists and user has access to it + _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Add comprehensive test data that covers all test scenarios + newTag := func(name, value string) model.Tag { + return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + } + + err = tagRepo.Add(1, + newTag("genre", "rock"), + newTag("genre", "pop"), + newTag("genre", "jazz"), + newTag("genre", "electronic"), + newTag("genre", "classical"), + newTag("genre", "ambient"), + newTag("genre", "techno"), + newTag("genre", "house"), + newTag("genre", "trance"), + newTag("genre", "Alternative Rock"), + newTag("genre", "Blues"), + newTag("genre", "Country"), + // These should not be counted as genres + newTag("mood", "happy"), + newTag("mood", "ambient"), + ) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("GetAll", func() { + It("should return all genres", func() { + genres, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(HaveLen(12)) + + // Verify that all returned items are genres (TagName = "genre") + genreNames := make([]string, len(genres)) + for i, genre := range genres { + genreNames[i] = genre.Name + } + Expect(genreNames).To(ContainElement("rock")) + Expect(genreNames).To(ContainElement("pop")) + Expect(genreNames).To(ContainElement("jazz")) + // Should not contain mood tags + Expect(genreNames).ToNot(ContainElement("happy")) + }) + + It("should support query options", func() { + // Test with limiting results + genres, err := repo.GetAll(model.QueryOptions{Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(HaveLen(1)) + }) + + It("should handle empty results gracefully", func() { + // Clear all genre tags + _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() + Expect(err).ToNot(HaveOccurred()) + + genres, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(BeEmpty()) + }) + Describe("filtering and sorting", func() { + It("should filter by name using like match", func() { + // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value") + options := model.QueryOptions{ + Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access + } + genres, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock" + + // Verify all returned genres contain "rock" in their name + for _, genre := range genres { + Expect(strings.ToLower(genre.Name)).To(ContainSubstring("rock")) + } + }) + + It("should sort by name in ascending order", func() { + // Test sorting by name with the fixed mapping + options := model.QueryOptions{ + Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" + Sort: "name", + } + genres, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(HaveLen(7)) + + Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int { + return strings.Compare(b.Name, a.Name) // Inverted to check descending order + })) + }) + + It("should sort by name in descending order", func() { + // Test sorting by name in descending order + options := model.QueryOptions{ + Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" + Sort: "name", + Order: "desc", + } + genres, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(genres).To(HaveLen(7)) + + Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int { + return strings.Compare(a.Name, b.Name) + })) + }) + }) + }) + + Describe("Count", func() { + It("should return correct count of genres", func() { + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(12))) // We have 12 genre tags + }) + + It("should handle zero count", func() { + // Clear all genre tags + _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() + Expect(err).ToNot(HaveOccurred()) + + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("should only count genre tags", func() { + // Add a non-genre tag + nonGenreTag := model.Tag{ + ID: id.NewTagID("mood", "energetic"), + TagName: "mood", + TagValue: "energetic", + } + err := tagRepo.Add(1, nonGenreTag) + Expect(err).ToNot(HaveOccurred()) + + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + // Count should not include the mood tag + Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags + }) + + It("should filter by name using like match", func() { + // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value") + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%rock%"}, + } + count, err := restRepo.Count(options) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + }) + + Describe("Read", func() { + It("should return existing genre", func() { + // Use one of the existing genres from our consolidated dataset + genreID := id.NewTagID("genre", "rock") + result, err := restRepo.Read(genreID) + Expect(err).ToNot(HaveOccurred()) + genre := result.(*model.Genre) + Expect(genre.ID).To(Equal(genreID)) + Expect(genre.Name).To(Equal("rock")) + }) + + It("should return error for non-existent genre", func() { + _, err := restRepo.Read("non-existent-id") + Expect(err).To(HaveOccurred()) + }) + + It("should not return non-genre tags", func() { + moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre + _, err := restRepo.Read(moodID) + Expect(err).To(HaveOccurred()) // Should not find it as a genre + }) + }) + + Describe("ReadAll", func() { + It("should return all genres through ReadAll", func() { + result, err := restRepo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + genres := result.(model.Genres) + Expect(genres).To(HaveLen(12)) // We have 12 genre tags + + genreNames := make([]string, len(genres)) + for i, genre := range genres { + genreNames[i] = genre.Name + } + // Check for some of our consolidated dataset genres + Expect(genreNames).To(ContainElement("rock")) + Expect(genreNames).To(ContainElement("pop")) + Expect(genreNames).To(ContainElement("jazz")) + }) + + It("should support rest query options", func() { + result, err := restRepo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + }) + }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) // Genre repository uses tag table + }) + }) + + Describe("NewInstance", func() { + It("should return new genre instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(&model.Genre{})) + }) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 9c305e52b..314b682bb 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -2,10 +2,13 @@ package persistence import ( "context" + "fmt" + "strconv" "sync" "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -68,41 +71,78 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } func (r *libraryRepository) Put(l *model.Library) error { - cols := map[string]any{ - "name": l.Name, - "path": l.Path, - "remote_path": l.RemotePath, - "updated_at": time.Now(), - } - if l.ID != 0 { - cols["id"] = l.ID + if l.ID == model.DefaultLibraryID { + currentLib, err := r.Get(1) + // if we are creating it, it's ok. + if err == nil { // it exists, so we are updating it + if currentLib.Path != l.Path { + return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation) + } + } } - sq := Insert(r.tableName).SetMap(cols). - Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path, - remote_path = excluded.remote_path, updated_at = excluded.updated_at`) - _, err := r.executeSQL(sq) + var err error + l.UpdatedAt = time.Now() + if l.ID == 0 { + // Insert with autoassigned ID + l.CreatedAt = time.Now() + err = r.db.Model(l).Insert() + } else { + // Try to update first + cols := map[string]any{ + "name": l.Name, + "path": l.Path, + "remote_path": l.RemotePath, + "default_new_users": l.DefaultNewUsers, + "updated_at": l.UpdatedAt, + } + sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) + rowsAffected, updateErr := r.executeSQL(sq) + if updateErr != nil { + return updateErr + } + + // If no rows were affected, the record doesn't exist, so insert it + if rowsAffected == 0 { + l.CreatedAt = time.Now() + l.UpdatedAt = time.Now() + err = r.db.Model(l).Insert() + } + } if err != nil { - libLock.Lock() - defer libLock.Unlock() - libCache[l.ID] = l.Path + return err } - return err -} -const hardCodedMusicFolderID = 1 + // Auto-assign all libraries to all admin users + sql := Expr(` +INSERT INTO user_library (user_id, library_id) +SELECT u.id, l.id +FROM user u +CROSS JOIN library l +WHERE u.is_admin = true +ON CONFLICT (user_id, library_id) DO NOTHING;`, + ) + if _, err = r.executeSQL(sql); err != nil { + return fmt.Errorf("failed to assign library to admin users: %w", err) + } + + libLock.Lock() + defer libLock.Unlock() + libCache[l.ID] = l.Path + return nil +} // TODO Remove this method when we have a proper UI to add libraries // This is a temporary method to store the music folder path from the config in the DB func (r *libraryRepository) StoreMusicFolder() error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). - Where(Eq{"id": hardCodedMusicFolderID}) + Where(Eq{"id": model.DefaultLibraryID}) _, err := r.executeSQL(sq) if err != nil { libLock.Lock() defer libLock.Unlock() - libCache[hardCodedMusicFolderID] = conf.Server.MusicFolder + libCache[model.DefaultLibraryID] = conf.Server.MusicFolder } return err } @@ -150,6 +190,7 @@ func (r *libraryRepository) ScanInProgress() (bool, error) { func (r *libraryRepository) RefreshStats(id int) error { var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var sizeRes struct{ Sum int64 } + var durationRes struct{ Sum float64 } err := run.Parallel( func() error { @@ -180,6 +221,9 @@ func (r *libraryRepository) RefreshStats(id int) error { func() error { return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) }, + func() error { + return r.queryOne(Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) + }, )() if err != nil { return err @@ -193,12 +237,34 @@ func (r *libraryRepository) RefreshStats(id int) error { Set("total_files", filesRes.Count). Set("total_missing_files", missingRes.Count). Set("total_size", sizeRes.Sum). + Set("total_duration", durationRes.Sum). Set("updated_at", time.Now()). Where(Eq{"id": id}) _, err = r.executeSQL(sq) return err } +func (r *libraryRepository) Delete(id int) error { + if !loggedUser(r.ctx).IsAdmin { + return model.ErrNotAuthorized + } + if id == 1 { + return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation) + } + + err := r.delete(Eq{"id": id}) + if err != nil { + return err + } + + // Clear cache entry for this library only if DB operation was successful + libLock.Lock() + defer libLock.Unlock() + delete(libCache, id) + + return nil +} + func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { sq := r.newSelect(ops...).Columns("*") res := model.Libraries{} @@ -206,4 +272,72 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, return res, err } +func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) { + sq := r.newSelect(ops...) + return r.count(sq) +} + +// User-library association methods + +func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { + sel := Select("u.*"). + From("user u"). + Join("user_library ul ON u.id = ul.user_id"). + Where(Eq{"ul.library_id": libraryID}). + OrderBy("u.name") + + var res model.Users + err := r.queryAll(sel, &res) + return res, err +} + +// REST interface methods + +func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *libraryRepository) Read(id string) (interface{}, error) { + idInt, err := strconv.Atoi(id) + if err != nil { + log.Trace(r.ctx, "invalid library id: %s", id, err) + return nil, rest.ErrNotFound + } + return r.Get(idInt) +} + +func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *libraryRepository) EntityName() string { + return "library" +} + +func (r *libraryRepository) NewInstance() interface{} { + return &model.Library{} +} + +func (r *libraryRepository) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + lib.ID = 0 // Reset ID to ensure we create a new library + err := r.Put(lib) + if err != nil { + return "", err + } + return strconv.Itoa(lib.ID), nil +} + +func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error { + lib := entity.(*model.Library) + idInt, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("invalid library ID: %s", id) + } + + lib.ID = idInt + return r.Put(lib) +} + var _ model.LibraryRepository = (*libraryRepository)(nil) +var _ rest.Repository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 280f254b5..6f4df1beb 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -22,6 +22,96 @@ var _ = Describe("LibraryRepository", func() { repo = NewLibraryRepository(ctx, conn) }) + AfterEach(func() { + // Clean up test libraries (keep ID 1 which is the default library) + _, _ = conn.NewQuery("DELETE FROM library WHERE id > 1").Execute() + }) + + Describe("Put", func() { + Context("when ID is 0", func() { + It("inserts a new library with autoassigned ID", func() { + lib := &model.Library{ + ID: 0, + Name: "Test Library", + Path: "/music/test", + } + + err := repo.Put(lib) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.ID).To(BeNumerically(">", 0)) + Expect(lib.CreatedAt).ToNot(BeZero()) + Expect(lib.UpdatedAt).ToNot(BeZero()) + + // Verify it was inserted + savedLib, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedLib.Name).To(Equal("Test Library")) + Expect(savedLib.Path).To(Equal("/music/test")) + }) + }) + + Context("when ID is non-zero and record exists", func() { + It("updates the existing record", func() { + // First create a library + lib := &model.Library{ + ID: 0, + Name: "Original Library", + Path: "/music/original", + } + err := repo.Put(lib) + Expect(err).ToNot(HaveOccurred()) + + originalID := lib.ID + originalCreatedAt := lib.CreatedAt + + // Now update it + lib.Name = "Updated Library" + lib.Path = "/music/updated" + err = repo.Put(lib) + Expect(err).ToNot(HaveOccurred()) + + // Verify it was updated, not inserted + Expect(lib.ID).To(Equal(originalID)) + Expect(lib.CreatedAt).To(Equal(originalCreatedAt)) + Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt)) + + // Verify the changes were saved + savedLib, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedLib.Name).To(Equal("Updated Library")) + Expect(savedLib.Path).To(Equal("/music/updated")) + }) + }) + + Context("when ID is non-zero but record doesn't exist", func() { + It("inserts a new record with the specified ID", func() { + lib := &model.Library{ + ID: 999, + Name: "New Library with ID", + Path: "/music/new", + } + + // Ensure the record doesn't exist + _, err := repo.Get(999) + Expect(err).To(HaveOccurred()) + + // Put should insert it + err = repo.Put(lib) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.ID).To(Equal(999)) + Expect(lib.CreatedAt).ToNot(BeZero()) + Expect(lib.UpdatedAt).ToNot(BeZero()) + + // Verify it was inserted with the correct ID + savedLib, err := repo.Get(999) + Expect(err).ToNot(HaveOccurred()) + Expect(savedLib.ID).To(Equal(999)) + Expect(savedLib.Name).To(Equal("New Library with ID")) + Expect(savedLib.Path).To(Equal("/music/new")) + }) + }) + }) + It("refreshes stats", func() { libBefore, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) @@ -32,6 +122,7 @@ var _ = Describe("LibraryRepository", func() { var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var sizeRes struct{ Sum int64 } + var durationRes struct{ Sum float64 } Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed()) Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed()) @@ -40,6 +131,7 @@ var _ = Describe("LibraryRepository", func() { Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed()) Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed()) Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed()) + Expect(conn.NewQuery("select ifnull(sum(duration),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&durationRes)).To(Succeed()) Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count))) Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count))) @@ -48,5 +140,6 @@ var _ = Describe("LibraryRepository", func() { Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count))) Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count))) Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum)) + Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum)) }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index dd22b1413..7c2ac5778 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -96,6 +96,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "genre_id": tagIDFilter, "missing": booleanFilter, "artists_id": artistFilter, + "library_id": libraryIdFilter, } // Add all album tags as filters for tag := range model.TagMappings() { @@ -116,6 +117,7 @@ func mediaFileRecentlyAddedSort() string { func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() query = r.withAnnotation(query, "media_file.id") + query = r.applyLibraryFilter(query) return r.count(query, options...) } @@ -134,10 +136,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { } func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path"). + sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id") sql = r.withAnnotation(sql, "media_file.id") - return r.withBookmark(sql, "media_file.id") + sql = r.withBookmark(sql, "media_file.id") + return r.applyLibraryFilter(sql) } func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { @@ -273,7 +276,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC if err != nil { return nil, err } - sel := r.newSelect().Columns("media_file.*", "library.path as library_path"). + sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where("pid in ("+subQText+")", subQArgs...). Where(Or{ @@ -294,15 +297,57 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC }, nil } -func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) { +// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries +func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.selectMediaFile().Where(And{ + NotEq{"media_file.library_id": missing.LibraryID}, + Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID}, + NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs + Eq{"media_file.suffix": missing.Suffix}, + Gt{"media_file.created_at": since}, + Eq{"media_file.missing": false}, + }).OrderBy("media_file.created_at DESC") + + var res dbMediaFiles + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries +func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.selectMediaFile().Where(And{ + NotEq{"media_file.library_id": missing.LibraryID}, + Eq{"media_file.title": missing.Title}, + Eq{"media_file.size": missing.Size}, + Eq{"media_file.suffix": missing.Suffix}, + Eq{"media_file.disc_number": missing.DiscNumber}, + Eq{"media_file.track_number": missing.TrackNumber}, + Eq{"media_file.album": missing.Album}, + Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID + Gt{"media_file.created_at": since}, + Eq{"media_file.missing": false}, + }).OrderBy("media_file.created_at DESC") + + var res dbMediaFiles + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) { var res dbMediaFiles if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectMediaFile(), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) if err != nil { return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &res, "title") + err := r.doSearch(r.selectMediaFile(options...), q, offset, size, includeMissing, &res, "title") if err != nil { return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 7edfeee1f..a3d5ebc74 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -34,6 +34,7 @@ func mf(mf model.MediaFile) model.MediaFile { mf.Tags = model.Tags{} mf.LibraryID = 1 mf.LibraryPath = "music" // Default folder + mf.LibraryName = "Music Library" mf.Participants = model.Participants{ model.RoleArtist: model.ParticipantList{ model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}}, @@ -47,6 +48,8 @@ func mf(mf model.MediaFile) model.MediaFile { func al(al model.Album) model.Album { al.LibraryID = 1 + al.LibraryPath = "music" + al.LibraryName = "Music Library" al.Discs = model.Discs{} al.Tags = model.Tags{} al.Participants = model.Participants{} @@ -138,14 +141,13 @@ var _ = BeforeSuite(func() { } } - //gr := NewGenreRepository(ctx, conn) - //for i := range testGenres { - // g := testGenres[i] - // err := gr.Put(&g) - // if err != nil { - // panic(err) - // } - //} + // Associate users with library 1 (default test library) + for i := range testUsers { + err := ur.SetUserLibraries(testUsers[i].ID, []int{1}) + if err != nil { + panic(err) + } + } alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { @@ -165,6 +167,15 @@ var _ = BeforeSuite(func() { } } + // Associate artists with library 1 (default test library) + lr := NewLibraryRepository(ctx, conn) + for i := range testArtists { + err := lr.AddArtist(1, testArtists[i].ID) + if err != nil { + panic(err) + } + } + mr := NewMediaFileRepository(ctx, conn) for i := range testSongs { err := mr.Put(&testSongs[i]) @@ -190,9 +201,9 @@ var _ = BeforeSuite(func() { Public: true, SongCount: 2, } - plsBest.AddTracks([]string{"1001", "1003"}) + plsBest.AddMediaFilesByID([]string{"1001", "1003"}) plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"} - plsCool.AddTracks([]string{"1004"}) + plsCool.AddMediaFilesByID([]string{"1004"}) testPlaylists = []*model.Playlist{&plsBest, &plsCool} pr := NewPlaylistRepository(ctx, conn) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index bdaaeeddb..046284e1f 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -161,7 +161,7 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) return nil, err } - pls.Tracks = tracks + pls.SetTracks(tracks) return pls, nil } @@ -263,7 +263,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { From("media_file").LeftJoin("annotation on (" + "annotation.item_id = media_file.id" + " AND annotation.item_type = 'media_file'" + - " AND annotation.user_id = '" + userId(r.ctx) + "')") + " AND annotation.user_id = '" + usr.ID + "')") sq = r.addCriteria(sq, rules) insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) _, err = r.executeSQL(insSql) @@ -379,6 +379,8 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { } func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { + sel = r.applyLibraryFilter(sel, "f") + userID := loggedUser(r.ctx).ID tracksQuery := sel. Columns( "coalesce(starred, 0) as starred", @@ -389,11 +391,12 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla "f.*", "playlist_tracks.*", "library.path as library_path", + "library.name as library_name", ). LeftJoin("annotation on (" + "annotation.item_id = media_file_id" + " AND annotation.item_type = 'media_file'" + - " AND annotation.user_id = '" + userId(r.ctx) + "')"). + " AND annotation.user_id = '" + userID + "')"). Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index b799d4912..15ae438d9 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -79,13 +79,13 @@ var _ = Describe("PlaylistRepository", func() { It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} - newPls.AddTracks([]string{"1004", "1003"}) + newPls.AddMediaFilesByID([]string{"1004", "1003"}) By("saves the playlist to the DB") Expect(repo.Put(&newPls)).To(BeNil()) By("adds repeated songs to a playlist and keeps the order") - newPls.AddTracks([]string{"1004"}) + newPls.AddMediaFilesByID([]string{"1004"}) Expect(repo.Put(&newPls)).To(BeNil()) saved, _ := repo.GetWithTracks(newPls.ID, true, false) Expect(saved.Tracks).To(HaveLen(3)) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 80925aa88..01eec0d02 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -47,7 +47,8 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool p.db = r.db p.tableName = "playlist_tracks" p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{ - "missing": booleanFilter, + "missing": booleanFilter, + "library_id": libraryIdFilter, }) p.setSortMappings( map[string]string{ @@ -84,11 +85,12 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er } func (r *playlistTrackRepository) Read(id string) (interface{}, error) { + userID := loggedUser(r.ctx).ID sel := r.newSelect(). LeftJoin("annotation on ("+ "annotation.item_id = media_file_id"+ " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = '"+userId(r.ctx)+"')"). + " AND annotation.user_id = '"+userID+"')"). Columns( "coalesce(starred, 0) as starred", "coalesce(play_count, 0) as play_count", diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index d0f88903e..ac0d8adeb 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -8,6 +8,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/pocketbase/dbx" ) @@ -82,7 +83,20 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S if err != nil { return nil, err } + + // Create context with user information for getParticipants call + // This is needed because the artist repository requires user context for multi-library support + userRepo := NewUserRepository(r.ctx, r.db) + user, err := userRepo.Get(res.ScrobbleEntry.UserID) + if err != nil { + return nil, err + } + // Temporarily use user context for getParticipants + originalCtx := r.ctx + r.ctx = request.WithUser(r.ctx, *user) res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) + r.ctx = originalCtx // Restore original context + if err != nil { return nil, err } diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index daf621ffe..6691b553c 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -15,15 +15,14 @@ import ( const annotationTable = "annotation" func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { - if userId(r.ctx) == invalidUserId { + userID := loggedUser(r.ctx).ID + if userID == invalidUserId { return query } query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - // item_ids are unique across different item_types, so the clause below is not needed - //" AND annotation.item_type = '"+r.tableName+"'"+ - " AND annotation.user_id = '"+userId(r.ctx)+"')"). + " AND annotation.user_id = '"+userID+"')"). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", @@ -42,8 +41,9 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec } func (r sqlRepository) annId(itemID ...string) And { + userID := loggedUser(r.ctx).ID return And{ - Eq{annotationTable + ".user_id": userId(r.ctx)}, + Eq{annotationTable + ".user_id": userID}, Eq{annotationTable + ".item_type": r.tableName}, Eq{annotationTable + ".item_id": itemID}, } @@ -56,8 +56,9 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin } c, err := r.executeSQL(upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { + userID := loggedUser(r.ctx).ID for _, itemID := range itemIDs { - values["user_id"] = userId(r.ctx) + values["user_id"] = userID values["item_type"] = r.tableName values["item_id"] = itemID ins := Insert(annotationTable).SetMap(values) @@ -86,8 +87,9 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { c, err := r.executeSQL(upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { + userID := loggedUser(r.ctx).ID values := map[string]interface{}{} - values["user_id"] = userId(r.ctx) + values["user_id"] = userID values["item_type"] = r.tableName values["item_id"] = itemID values["play_count"] = 1 diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ea22389a2..9e7a58713 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -49,27 +49,14 @@ type sqlRepository struct { const invalidUserId = "-1" -func userId(ctx context.Context) string { - if user, ok := request.UserFrom(ctx); !ok { - return invalidUserId - } else { - return user.ID - } -} - func loggedUser(ctx context.Context) *model.User { if user, ok := request.UserFrom(ctx); !ok { - return &model.User{} + return &model.User{ID: invalidUserId} } else { return &user } } -func isAdmin(ctx context.Context) bool { - user := loggedUser(ctx) - return user.IsAdmin -} - func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -199,10 +186,52 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } +func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { + return func(field string, value any) Sqlizer { + if r.tableName != "" { + field = r.tableName + "." + field + } + return filter(field, value) + } +} + +// libraryIdFilter is a filter function to be added to resources that have a library_id column. +func libraryIdFilter(_ string, value interface{}) Sqlizer { + return Eq{"library_id": value} +} + +// applyLibraryFilter adds library filtering to queries for tables that have a library_id column +// This ensures users only see content from libraries they have access to +func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder { + user := loggedUser(r.ctx) + + // Admin users see all content + if user.IsAdmin { + return sq + } + + // Get user's accessible library IDs + userID := loggedUser(r.ctx).ID + if userID == invalidUserId { + // No user context - return empty result set + return sq.Where(Eq{"1": "0"}) + } + + table := r.tableName + if len(tableName) > 0 { + table = tableName[0] + } + + // Use subquery to filter by user's library access + // This approach doesn't require DataStore in context + return sq.Where(Expr(table+".library_id IN ("+ + "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", userID)) +} + func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := md5.Sum([]byte(userId(r.ctx))) + userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) return fmt.Sprintf("%s|%x", r.tableName, userIDHash) } diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 56645ea21..52c4b8e9c 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -15,21 +15,20 @@ import ( const bookmarkTable = "bookmark" func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder { - if userId(r.ctx) == invalidUserId { + userID := loggedUser(r.ctx).ID + if userID == invalidUserId { return query } return query. LeftJoin("bookmark on (" + "bookmark.item_id = " + idField + - // item_ids are unique across different item_types, so the clause below is not needed - //" AND bookmark.item_type = '" + r.tableName + "'" + - " AND bookmark.user_id = '" + userId(r.ctx) + "')"). + " AND bookmark.user_id = '" + userID + "')"). Columns("coalesce(position, 0) as bookmark_position") } func (r sqlRepository) bmkID(itemID ...string) And { return And{ - Eq{bookmarkTable + ".user_id": userId(r.ctx)}, + Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID}, Eq{bookmarkTable + ".item_type": r.tableName}, Eq{bookmarkTable + ".item_id": itemID}, } diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index d7b48f23e..b92e18e60 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -1,12 +1,15 @@ package persistence import ( + "context" "encoding/json" "fmt" "strings" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" ) // Format of a tag in the DB @@ -55,3 +58,106 @@ func tagIDFilter(name string, idValue any) Sqlizer { }, ) } + +// tagLibraryIdFilter filters tags based on library access through the library_tag table +func tagLibraryIdFilter(_ string, value interface{}) Sqlizer { + return Eq{"library_tag.library_id": value} +} + +// baseTagRepository provides common functionality for all tag-based repositories. +// It handles CRUD operations with optional filtering by tag name. +type baseTagRepository struct { + sqlRepository + tagFilter *model.TagName // nil = no filter (all tags), non-nil = filter by specific tag name +} + +// newBaseTagRepository creates a new base tag repository with optional tag filtering. +// If tagFilter is nil, the repository will work with all tags. +// If tagFilter is provided, the repository will only work with tags of that specific name. +func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { + r := &baseTagRepository{ + tagFilter: tagFilter, + } + r.ctx = ctx + r.db = db + r.tableName = "tag" + r.registerModel(&model.Tag{}, map[string]filterFunc{ + "name": containsFilter("tag_value"), + "library_id": tagLibraryIdFilter, + }) + r.setSortMappings(map[string]string{ + "name": "tag_value", + }) + return r +} + +// newSelect overrides the base implementation to apply tag name filtering and library filtering. +func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder { + user := loggedUser(r.ctx) + if user.ID == invalidUserId { + // No user context - return empty result set + return SelectBuilder{}.Where(Eq{"1": "0"}) + } + sq := r.sqlRepository.newSelect(options...) + if r.tagFilter != nil { + sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) + } + sq = sq.Columns( + "tag.id", + "tag.tag_value as name", + "COALESCE(SUM(library_tag.album_count), 0) as album_count", + "COALESCE(SUM(library_tag.media_file_count), 0) as song_count", + ). + LeftJoin("library_tag on library_tag.tag_id = tag.id"). + // Apply library filtering by joining only with accessible libraries + Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID). + GroupBy("tag.id", "tag.tag_value") + return sq +} + +// ResourceRepository interface implementation + +func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { + // Create a query that counts distinct tags without GROUP BY + user := loggedUser(r.ctx) + if user.ID == invalidUserId { + return 0, nil + } + + // Build the same base query as newSelect but for counting + sq := Select() + if r.tagFilter != nil { + sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) + } + + // Apply the same joins as newSelect + sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id"). + Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) + + return r.count(sq, r.parseRestOptions(r.ctx, options...)) +} + +func (r *baseTagRepository) Read(id string) (interface{}, error) { + query := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.Tag + err := r.queryOne(query, &res) + return &res, err +} + +func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") + var res model.TagList + err := r.queryAll(query, &res) + return res, err +} + +func (r *baseTagRepository) EntityName() string { + return "tag" +} + +func (r *baseTagRepository) NewInstance() interface{} { + return model.Tag{} +} + +// Interface compliance check +var _ model.ResourceRepository = (*baseTagRepository)(nil) diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go new file mode 100644 index 000000000..8017528fe --- /dev/null +++ b/persistence/tag_library_filtering_test.go @@ -0,0 +1,228 @@ +package persistence + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("Tag Library Filtering", func() { + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + // Clean up all relevant tables + db := GetDBXBuilder() + _, err := db.NewQuery("DELETE FROM library_tag").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("DELETE FROM tag").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("DELETE FROM user_library WHERE user_id != 'userid' AND user_id != '2222'").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Create test libraries + _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (2, 'Library 2', '/music/lib2')").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (3, 'Library 3', '/music/lib3')").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Ensure admin user has access to all libraries (since admin users should have access to all libraries) + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 2)").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 3)").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Set up test tags + newTag := func(name, value string) model.Tag { + return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + } + + // Create tags in admin context + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) + + // Add tags to different libraries + err = tagRepo.Add(1, newTag("genre", "rock")) + Expect(err).ToNot(HaveOccurred()) + err = tagRepo.Add(2, newTag("genre", "pop")) + Expect(err).ToNot(HaveOccurred()) + err = tagRepo.Add(3, newTag("genre", "jazz")) + Expect(err).ToNot(HaveOccurred()) + err = tagRepo.Add(2, newTag("genre", "rock")) + Expect(err).ToNot(HaveOccurred()) + + // Update counts manually for testing + _, err = db.NewQuery("UPDATE library_tag SET album_count = 5, media_file_count = 20 WHERE tag_id = {:tagId} AND library_id = 1").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("UPDATE library_tag SET album_count = 3, media_file_count = 10 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "pop")}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("UPDATE library_tag SET album_count = 2, media_file_count = 8 WHERE tag_id = {:tagId} AND library_id = 3").Bind(dbx.Params{"tagId": id.NewTagID("genre", "jazz")}).Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("UPDATE library_tag SET album_count = 1, media_file_count = 4 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Set up user library access - Regular user has access to libraries 1 and 2 only + _, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ('2222', 2)").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("TagRepository Library Filtering", func() { + Context("Admin User", func() { + It("should see all tags regardless of library", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + tags, err := repo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + Expect(tagList).To(HaveLen(3)) + }) + }) + + Context("Regular User with Limited Library Access", func() { + It("should only see tags from accessible libraries", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + tags, err := repo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3) + Expect(tagList).To(HaveLen(2)) + }) + + It("should respect explicit library_id filters within accessible libraries", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + // Filter by library 2 (user has access to libraries 1 and 2) + tags, err := repo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{ + "library_id": 2, + }, + }) + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should see only tags from library 2: pop and rock(lib2) + Expect(tagList).To(HaveLen(2)) + + // Verify the tags are correct + tagValues := make([]string, len(tagList)) + for i, tag := range tagList { + tagValues[i] = tag.TagValue + } + Expect(tagValues).To(ContainElements("pop", "rock")) + }) + + It("should not return tags when filtering by inaccessible library", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + // Try to filter by library 3 (user doesn't have access) + tags, err := repo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{ + "library_id": 3, + }, + }) + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should return no tags since user can't access library 3 + Expect(tagList).To(HaveLen(0)) + }) + + It("should filter by library 1 correctly", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + // Filter by library 1 (user has access) + tags, err := repo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{ + "library_id": 1, + }, + }) + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should see only rock from library 1 + Expect(tagList).To(HaveLen(1)) + Expect(tagList[0].TagValue).To(Equal("rock")) + }) + }) + + Context("Admin User with Explicit Library Filtering", func() { + It("should see all tags when no filter is applied", func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + tags, err := repo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + Expect(tagList).To(HaveLen(3)) + }) + + It("should respect explicit library_id filters", func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + // Filter by library 3 + tags, err := repo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{ + "library_id": 3, + }, + }) + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should see only jazz from library 3 + Expect(tagList).To(HaveLen(1)) + Expect(tagList[0].TagValue).To(Equal("jazz")) + }) + + It("should filter by library 2 correctly", func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + // Filter by library 2 + tags, err := repo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{ + "library_id": 2, + }, + }) + Expect(err).ToNot(HaveOccurred()) + tagList := tags.(model.TagList) + + // Should see pop and rock from library 2 + Expect(tagList).To(HaveLen(2)) + + tagValues := make([]string, len(tagList)) + for i, tag := range tagList { + tagValues[i] = tag.TagValue + } + Expect(tagValues).To(ContainElements("pop", "rock")) + }) + }) + }) +}) diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index d63584af0..729208999 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -7,26 +7,22 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) type tagRepository struct { - sqlRepository + *baseTagRepository } func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository { - r := &tagRepository{} - r.ctx = ctx - r.db = db - r.tableName = "tag" - r.registerModel(&model.Tag{}, nil) - return r + return &tagRepository{ + baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags + } } -func (r *tagRepository) Add(tags ...model.Tag) error { +func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error { for chunk := range slices.Chunk(tags, 200) { sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value"). Suffix("on conflict (id) do nothing") @@ -37,34 +33,41 @@ func (r *tagRepository) Add(tags ...model.Tag) error { if err != nil { return err } + + // Create library_tag entries for library filtering + libSq := Insert("library_tag").Columns("tag_id", "library_id", "album_count", "media_file_count"). + Suffix("on conflict (tag_id, library_id) do nothing") + for _, t := range chunk { + libSq = libSq.Values(t.ID, libraryID, 0, 0) + } + _, err = r.executeSQL(libSq) + if err != nil { + return fmt.Errorf("adding library_tag entries: %w", err) + } } return nil } -// UpdateCounts updates the album_count and media_file_count columns in the tag_counts table. +// UpdateCounts updates the library_tag table with per-library statistics. // Only genres are being updated for now. func (r *tagRepository) UpdateCounts() error { template := ` -with updated_values as ( - select jt.value as id, count(distinct %[1]s.id) as %[1]s_count - from %[1]s - join json_tree(tags, '$.genre') as jt - where atom is not null - and key = 'id' - group by jt.value -) -update tag -set %[1]s_count = updated_values.%[1]s_count -from updated_values -where tag.id = updated_values.id; +INSERT INTO library_tag (tag_id, library_id, %[1]s_count) +SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count +FROM %[1]s +JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id' +GROUP BY jt.value, %[1]s.library_id +ON CONFLICT (tag_id, library_id) +DO UPDATE SET %[1]s_count = excluded.%[1]s_count; ` + for _, table := range []string{"album", "media_file"} { start := time.Now() query := Expr(fmt.Sprintf(template, table)) c, err := r.executeSQL(query) - log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c) + log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) if err != nil { - return fmt.Errorf("updating %s tag counts: %w", table, err) + return fmt.Errorf("updating %s library tag counts: %w", table, err) } } return nil @@ -74,6 +77,11 @@ func (r *tagRepository) purgeUnused() error { del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt + where atom is not null + and key = 'id' + UNION + select jt.value + from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null and key = 'id') `) @@ -87,30 +95,4 @@ func (r *tagRepository) purgeUnused() error { return err } -func (r *tagRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.count(r.newSelect(), r.parseRestOptions(r.ctx, options...)) -} - -func (r *tagRepository) Read(id string) (interface{}, error) { - query := r.newSelect().Columns("*").Where(Eq{"id": id}) - var res model.Tag - err := r.queryOne(query, &res) - return &res, err -} - -func (r *tagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { - query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") - var res model.TagList - err := r.queryAll(query, &res) - return res, err -} - -func (r *tagRepository) EntityName() string { - return "tag" -} - -func (r *tagRepository) NewInstance() interface{} { - return model.Tag{} -} - var _ model.ResourceRepository = &tagRepository{} diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go new file mode 100644 index 000000000..9b8f93cd9 --- /dev/null +++ b/persistence/tag_repository_test.go @@ -0,0 +1,249 @@ +package persistence + +import ( + "context" + "slices" + "strings" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagRepository", func() { + var repo model.TagRepository + var restRepo model.ResourceRepository + var ctx context.Context + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo = tagRepo + restRepo = tagRepo.(model.ResourceRepository) + + // Clean the database before each test to ensure isolation + db := GetDBXBuilder() + _, err := db.NewQuery("DELETE FROM tag").Execute() + Expect(err).ToNot(HaveOccurred()) + _, err = db.NewQuery("DELETE FROM library_tag").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Ensure library 1 exists (if it doesn't already) + _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Ensure the admin user has access to library 1 + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Add comprehensive test data that covers all test scenarios + newTag := func(name, value string) model.Tag { + return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + } + + err = repo.Add(1, + // Genre tags + newTag("genre", "rock"), + newTag("genre", "pop"), + newTag("genre", "jazz"), + newTag("genre", "electronic"), + newTag("genre", "classical"), + newTag("genre", "ambient"), + newTag("genre", "techno"), + newTag("genre", "house"), + newTag("genre", "trance"), + newTag("genre", "Alternative Rock"), + newTag("genre", "Blues"), + newTag("genre", "Country"), + // Mood tags + newTag("mood", "happy"), + newTag("mood", "sad"), + newTag("mood", "energetic"), + newTag("mood", "calm"), + // Other tag types + newTag("instrument", "guitar"), + newTag("instrument", "piano"), + newTag("decade", "1980s"), + newTag("decade", "1990s"), + ) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Add", func() { + It("should handle adding new tags", func() { + newTag := model.Tag{ + ID: id.NewTagID("genre", "experimental"), + TagName: "genre", + TagValue: "experimental", + } + + err := repo.Add(1, newTag) + Expect(err).ToNot(HaveOccurred()) + + // Verify tag was added + result, err := restRepo.Read(newTag.ID) + Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) + Expect(resultTag.TagValue).To(Equal("experimental")) + + // Check count increased + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new + }) + + It("should handle duplicate tags gracefully", func() { + // Try to add a duplicate tag + duplicateTag := model.Tag{ + ID: id.NewTagID("genre", "rock"), // This already exists + TagName: "genre", + TagValue: "rock", + } + + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(20))) // Still 20 tags + + err = repo.Add(1, duplicateTag) + Expect(err).ToNot(HaveOccurred()) // Should not error + + // Count should remain the same + count, err = restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(20))) // Still 20 tags + }) + }) + + Describe("UpdateCounts", func() { + It("should update tag counts successfully", func() { + err := repo.UpdateCounts() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should handle empty database gracefully", func() { + // Clear the database first + db := GetDBXBuilder() + _, err := db.NewQuery("DELETE FROM tag").Execute() + Expect(err).ToNot(HaveOccurred()) + + err = repo.UpdateCounts() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Count", func() { + It("should return correct count of tags", func() { + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(20))) // From the test dataset + }) + }) + + Describe("Read", func() { + It("should return existing tag", func() { + rockID := id.NewTagID("genre", "rock") + result, err := restRepo.Read(rockID) + Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) + Expect(resultTag.ID).To(Equal(rockID)) + Expect(resultTag.TagName).To(Equal(model.TagName("genre"))) + Expect(resultTag.TagValue).To(Equal("rock")) + }) + + It("should return error for non-existent tag", func() { + _, err := restRepo.Read("non-existent-id") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ReadAll", func() { + It("should return all tags from dataset", func() { + result, err := restRepo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(20)) + }) + + It("should filter tags by partial value correctly", func() { + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock' + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock" + + // Verify all returned tags contain 'rock' in their value + for _, tag := range tags { + Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("rock")) + } + }) + + It("should filter tags by partial value using LIKE", func() { + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e' + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock + + // Verify all returned tags contain 'e' in their value + for _, tag := range tags { + Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("e")) + } + }) + + It("should sort tags by value ascending", func() { + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r' + Sort: "name", + Order: "asc", + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(7)) + + Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { + return strings.Compare(strings.ToLower(a.TagValue), strings.ToLower(b.TagValue)) + })) + }) + + It("should sort tags by value descending", func() { + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r' + Sort: "name", + Order: "desc", + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(7)) + + Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { + return strings.Compare(strings.ToLower(b.TagValue), strings.ToLower(a.TagValue)) // Descending order + })) + }) + }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) + }) + }) + + Describe("NewInstance", func() { + It("should return new tag instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(model.Tag{})) + }) + }) +}) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index bdcbe7262..125f57541 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -41,7 +41,7 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, } func (r *transcodingRepository) Put(t *model.Transcoding) error { - if !isAdmin(r.ctx) { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } _, err := r.put(t.ID, t) @@ -72,7 +72,7 @@ func (r *transcodingRepository) NewInstance() interface{} { } func (r *transcodingRepository) Save(entity interface{}) (string, error) { - if !isAdmin(r.ctx) { + if !loggedUser(r.ctx).IsAdmin { return "", rest.ErrPermissionDenied } t := entity.(*model.Transcoding) @@ -84,7 +84,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) { } func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error { - if !isAdmin(r.ctx) { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } t := entity.(*model.Transcoding) @@ -97,7 +97,7 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st } func (r *transcodingRepository) Delete(id string) error { - if !isAdmin(r.ctx) { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } err := r.delete(Eq{"id": id}) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 073e32963..a7181b1a7 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "strings" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -24,6 +26,26 @@ type userRepository struct { sqlRepository } +type dbUser struct { + *model.User `structs:",flatten"` + LibrariesJSON string `structs:"-" json:"-"` +} + +func (u *dbUser) PostScan() error { + if u.LibrariesJSON != "" { + if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil { + return fmt.Errorf("parsing user libraries from db: %w", err) + } + } + return nil +} + +type dbUsers []dbUser + +func (us dbUsers) toModels() model.Users { + return slice.Map(us, func(u dbUser) model.User { return *u.User }) +} + var ( once sync.Once encKey []byte @@ -33,8 +55,10 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r := &userRepository{} r.ctx = ctx r.db = db + r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ "password": invalidFilter(ctx), + "name": r.withTableName(startsWithFilter), }) once.Do(func() { _ = r.initPasswordEncryptionKey() @@ -42,28 +66,48 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository return r } +// selectUserWithLibraries returns a SelectBuilder that includes library information +func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). + Columns(`user.*`, + `COALESCE(json_group_array(json_object( + 'id', library.id, + 'name', library.name, + 'path', library.path, + 'remote_path', library.remote_path, + 'last_scan_at', library.last_scan_at, + 'last_scan_started_at', library.last_scan_started_at, + 'full_scan_in_progress', library.full_scan_in_progress, + 'updated_at', library.updated_at, + 'created_at', library.created_at + )) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`). + LeftJoin("user_library ul ON user.id = ul.user_id"). + LeftJoin("library ON ul.library_id = library.id"). + GroupBy("user.id") +} + func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { return r.count(Select(), qo...) } func (r *userRepository) Get(id string) (*model.User, error) { - sel := r.newSelect().Columns("*").Where(Eq{"id": id}) - var res model.User + sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) + var res dbUser err := r.queryOne(sel, &res) if err != nil { return nil, err } - return &res, nil + return res.User, nil } func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { - sel := r.newSelect(options...).Columns("*") - res := model.Users{} + sel := r.selectUserWithLibraries(options...) + var res dbUsers err := r.queryAll(sel, &res) if err != nil { return nil, err } - return res, nil + return res.toModels(), nil } func (r *userRepository) Put(u *model.User) error { @@ -79,38 +123,65 @@ func (r *userRepository) Put(u *model.User) error { return fmt.Errorf("error converting user to SQL args: %w", err) } delete(values, "current_password") + + // Save/update the user update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) count, err := r.executeSQL(update) if err != nil { return err } - if count > 0 { - return nil + + isNewUser := count == 0 + if isNewUser { + values["created_at"] = time.Now() + insert := Insert(r.tableName).SetMap(values) + _, err = r.executeSQL(insert) + if err != nil { + return err + } } - values["created_at"] = time.Now() - insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(insert) - return err + + // Auto-assign all libraries to admin users in a single SQL operation + if u.IsAdmin { + sql := Expr( + "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library", + u.ID, + ) + if _, err := r.executeSQL(sql); err != nil { + return fmt.Errorf("failed to assign all libraries to admin user: %w", err) + } + } else if isNewUser { // Only for new regular users + // Auto-assign default libraries to new regular users + sql := Expr( + "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true", + u.ID, + ) + if _, err := r.executeSQL(sql); err != nil { + return fmt.Errorf("failed to assign default libraries to new user: %w", err) + } + } + + return nil } func (r *userRepository) FindFirstAdmin() (*model.User, error) { - sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true}) - var usr model.User + sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) + var usr dbUser err := r.queryOne(sel, &usr) if err != nil { return nil, err } - return &usr, nil + return usr.User, nil } func (r *userRepository) FindByUsername(username string) (*model.User, error) { - sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username)) - var usr model.User + sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) + var usr dbUser err := r.queryOne(sel, &usr) if err != nil { return nil, err } - return &usr, nil + return usr.User, nil } func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { @@ -365,6 +436,39 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error { return nil } +// Library association methods + +func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) { + sel := Select("l.*"). + From("library l"). + Join("user_library ul ON l.id = ul.library_id"). + Where(Eq{"ul.user_id": userID}). + OrderBy("l.name") + + var res model.Libraries + err := r.queryAll(sel, &res) + return res, err +} + +func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error { + // Remove existing associations + delSql := Delete("user_library").Where(Eq{"user_id": userID}) + if _, err := r.executeSQL(delSql); err != nil { + return err + } + + // Add new associations + if len(libraryIDs) > 0 { + insert := Insert("user_library").Columns("user_id", "library_id") + for _, libID := range libraryIDs { + insert = insert.Values(userID, libID) + } + _, err := r.executeSQL(insert) + return err + } + return nil +} + var _ model.UserRepository = (*userRepository)(nil) var _ rest.Repository = (*userRepository)(nil) var _ rest.Persistable = (*userRepository)(nil) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 7b1ad79d7..24223857f 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -235,4 +236,330 @@ var _ = Describe("UserRepository", func() { Expect(err).To(MatchError("fake error")) }) }) + + Describe("Library Association Methods", func() { + var userID string + var library1, library2 model.Library + + BeforeEach(func() { + // Create a test user first to satisfy foreign key constraints + testUser := model.User{ + ID: "test-user-id", + UserName: "testuser", + Name: "Test User", + Email: "test@example.com", + NewPassword: "password", + IsAdmin: false, + } + Expect(repo.Put(&testUser)).To(BeNil()) + userID = testUser.ID + + library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"} + library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"} + + // Create test libraries + libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + }) + + AfterEach(func() { + // Clean up user-library associations to ensure test isolation + _ = repo.SetUserLibraries(userID, []int{}) + + // Clean up test libraries to ensure isolation between test groups + libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + }) + + Describe("GetUserLibraries", func() { + It("returns empty list when user has no library associations", func() { + libraries, err := repo.GetUserLibraries("non-existent-user") + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(0)) + }) + + It("returns user's associated libraries", func() { + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + Expect(err).To(BeNil()) + + libraries, err := repo.GetUserLibraries(userID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(2)) + + libIDs := []int{libraries[0].ID, libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + }) + + Describe("SetUserLibraries", func() { + It("sets user's library associations", func() { + libraryIDs := []int{library1.ID, library2.ID} + err := repo.SetUserLibraries(userID, libraryIDs) + Expect(err).To(BeNil()) + + libraries, err := repo.GetUserLibraries(userID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(2)) + }) + + It("replaces existing associations", func() { + // Set initial associations + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + Expect(err).To(BeNil()) + + // Replace with just one library + err = repo.SetUserLibraries(userID, []int{library1.ID}) + Expect(err).To(BeNil()) + + libraries, err := repo.GetUserLibraries(userID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(1)) + Expect(libraries[0].ID).To(Equal(library1.ID)) + }) + + It("removes all associations when passed empty slice", func() { + // Set initial associations + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + Expect(err).To(BeNil()) + + // Remove all + err = repo.SetUserLibraries(userID, []int{}) + Expect(err).To(BeNil()) + + libraries, err := repo.GetUserLibraries(userID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(0)) + }) + }) + }) + + Describe("Admin User Auto-Assignment", func() { + var ( + libRepo model.LibraryRepository + library1 model.Library + library2 model.Library + initialLibCount int + ) + + BeforeEach(func() { + libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + + // Count initial libraries + existingLibs, err := libRepo.GetAll() + Expect(err).To(BeNil()) + initialLibCount = len(existingLibs) + + library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"} + library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"} + + // Create test libraries + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + }) + + AfterEach(func() { + // Clean up test libraries and their associations + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + + // Clean up user-library associations for these test libraries + _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + }) + + It("automatically assigns all libraries to admin users when created", func() { + adminUser := model.User{ + ID: "admin-user-id-1", + UserName: "adminuser1", + Name: "Admin User", + Email: "admin1@example.com", + NewPassword: "password", + IsAdmin: true, + } + + err := repo.Put(&adminUser) + Expect(err).To(BeNil()) + + // Admin should automatically have access to all libraries (including existing ones) + libraries, err := repo.GetUserLibraries(adminUser.ID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries + + libIDs := make([]int, len(libraries)) + for i, lib := range libraries { + libIDs[i] = lib.ID + } + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("automatically assigns all libraries to admin users when updated", func() { + // Create regular user first + regularUser := model.User{ + ID: "regular-user-id-1", + UserName: "regularuser1", + Name: "Regular User", + Email: "regular1@example.com", + NewPassword: "password", + IsAdmin: false, + } + + err := repo.Put(®ularUser) + Expect(err).To(BeNil()) + + // Give them access to just one library + err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID}) + Expect(err).To(BeNil()) + + // Promote to admin + regularUser.IsAdmin = true + err = repo.Put(®ularUser) + Expect(err).To(BeNil()) + + // Should now have access to all libraries (including existing ones) + libraries, err := repo.GetUserLibraries(regularUser.ID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries + + libIDs := make([]int, len(libraries)) + for i, lib := range libraries { + libIDs[i] = lib.ID + } + // Should include our test libraries plus all existing ones + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("assigns default libraries to regular users", func() { + regularUser := model.User{ + ID: "regular-user-id-2", + UserName: "regularuser2", + Name: "Regular User", + Email: "regular2@example.com", + NewPassword: "password", + IsAdmin: false, + } + + err := repo.Put(®ularUser) + Expect(err).To(BeNil()) + + // Regular user should be assigned to default libraries (library ID 1 from migration) + libraries, err := repo.GetUserLibraries(regularUser.ID) + Expect(err).To(BeNil()) + Expect(libraries).To(HaveLen(1)) + Expect(libraries[0].ID).To(Equal(1)) + Expect(libraries[0].DefaultNewUsers).To(BeTrue()) + }) + }) + + Describe("Libraries Field Population", func() { + var ( + libRepo model.LibraryRepository + library1 model.Library + library2 model.Library + testUser model.User + ) + + BeforeEach(func() { + libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"} + library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"} + + // Create test libraries + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + + // Create test user + testUser = model.User{ + ID: "field-test-user", + UserName: "fieldtestuser", + Name: "Field Test User", + Email: "fieldtest@example.com", + NewPassword: "password", + IsAdmin: false, + } + Expect(repo.Put(&testUser)).To(BeNil()) + + // Assign libraries to user + Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) + }) + + AfterEach(func() { + // Clean up test libraries and their associations + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID}) + + // Clean up user-library associations for these test libraries + _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + }) + + It("populates Libraries field when getting a single user", func() { + user, err := repo.Get(testUser.ID) + Expect(err).To(BeNil()) + Expect(user.Libraries).To(HaveLen(2)) + + libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + + // Check that library details are properly populated + for _, lib := range user.Libraries { + if lib.ID == library1.ID { + Expect(lib.Name).To(Equal("Field Test Library 1")) + Expect(lib.Path).To(Equal("/field/test/path1")) + } else if lib.ID == library2.ID { + Expect(lib.Name).To(Equal("Field Test Library 2")) + Expect(lib.Path).To(Equal("/field/test/path2")) + } + } + }) + + It("populates Libraries field when getting all users", func() { + users, err := repo.(*userRepository).GetAll() + Expect(err).To(BeNil()) + + // Find our test user in the results + var foundUser *model.User + for i := range users { + if users[i].ID == testUser.ID { + foundUser = &users[i] + break + } + } + + Expect(foundUser).ToNot(BeNil()) + Expect(foundUser.Libraries).To(HaveLen(2)) + + libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("populates Libraries field when finding user by username", func() { + user, err := repo.FindByUsername(testUser.UserName) + Expect(err).To(BeNil()) + Expect(user.Libraries).To(HaveLen(2)) + + libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("returns default Libraries array for new regular users", func() { + // Create a user with no explicit library associations - should get default libraries + userWithoutLibs := model.User{ + ID: "no-libs-user", + UserName: "nolibsuser", + Name: "No Libs User", + Email: "nolibs@example.com", + NewPassword: "password", + IsAdmin: false, + } + Expect(repo.Put(&userWithoutLibs)).To(BeNil()) + defer func() { + _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) + }() + + user, err := repo.Get(userWithoutLibs.ID) + Expect(err).To(BeNil()) + Expect(user.Libraries).ToNot(BeNil()) + // Regular users should be assigned to default libraries (library ID 1 from migration) + Expect(user.Libraries).To(HaveLen(1)) + Expect(user.Libraries[0].ID).To(Equal(1)) + }) + }) }) diff --git a/scanner/controller.go b/scanner/controller.go index f3fdd593f..c1347077a 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -116,6 +116,24 @@ type controller struct { changesDetected bool } +// getLastScanTime returns the most recent scan time across all libraries +func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { + libs, err := s.ds.Library(ctx).GetAll(model.QueryOptions{ + Sort: "last_scan_at", + Order: "desc", + Max: 1, + }) + if err != nil { + return time.Time{}, fmt.Errorf("getting libraries: %w", err) + } + + if len(libs) == 0 { + return time.Time{}, nil + } + + return libs[0].LastScanAt, nil +} + // getScanInfo retrieves scan status from the database func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) { lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") @@ -128,10 +146,10 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed if running.Load() { elapsed = time.Since(startTime) } else { - // If scan is not running, try to get the last scan time for the library - lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library - if err == nil { - elapsed = lib.LastScanAt.Sub(startTime) + // If scan is not running, calculate elapsed time using the most recent scan time + lastScanTime, err := s.getLastScanTime(ctx) + if err == nil && !lastScanTime.IsZero() { + elapsed = lastScanTime.Sub(startTime) } } } @@ -141,9 +159,9 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed } func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { - lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library + lastScanTime, err := s.getLastScanTime(ctx) if err != nil { - return nil, fmt.Errorf("getting library: %w", err) + return nil, fmt.Errorf("getting last scan time: %w", err) } scanType, elapsed, lastErr := s.getScanInfo(ctx) @@ -151,7 +169,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { if running.Load() { status := &StatusInfo{ Scanning: true, - LastScan: lib.LastScanAt, + LastScan: lastScanTime, Count: s.count.Load(), FolderCount: s.folderCount.Load(), LastError: lastErr, @@ -167,7 +185,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { } return &StatusInfo{ Scanning: false, - LastScan: lib.LastScanAt, + LastScan: lastScanTime, Count: uint32(count), FolderCount: uint32(folderCount), LastError: lastErr, @@ -198,7 +216,6 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin // Prepare the context for the scan ctx := request.AddValues(s.rootCtx, requestCtx) - ctx = events.BroadcastToAll(ctx) ctx = auth.WithAdminUser(ctx, s.ds) // Send the initial scan status event @@ -218,7 +235,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") - s.broker.SendMessage(ctx, &events.RefreshResource{}) + s.broker.SendBroadcastMessage(ctx, &events.RefreshResource{}) } // Send the final scan status event, with totals if count, folderCount, err := s.getCounters(ctx); err != nil { @@ -297,5 +314,5 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres } func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { - s.broker.SendMessage(ctx, status) + s.broker.SendBroadcastMessage(ctx, status) } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 4f6576a39..e551e15b1 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" @@ -32,7 +31,6 @@ var _ = Describe("Controller", func() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance()) - Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed()) }) It("includes last scan error", func() { diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 8397d6924..e04f10c70 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -28,6 +28,7 @@ import ( func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders { var jobs []*scanJob + var updatedLibs []model.Library for _, lib := range libs { if lib.LastScanStartedAt.IsZero() { err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) @@ -54,7 +55,12 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor continue } jobs = append(jobs, job) + updatedLibs = append(updatedLibs, lib) } + + // Update the state with the libraries that have been processed and have their scan timestamps set + state.libraries = updatedLibs + return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} } @@ -336,7 +342,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } // Save all tags to DB - err = tagRepo.Add(entry.tags...) + err = tagRepo.Add(entry.job.lib.ID, entry.tags...) if err != nil { log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err) return err @@ -418,12 +424,14 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, if prevID == "" { return nil } + // Reassign annotation from previous album to new album log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name) if err := repo.ReassignAnnotation(prevID, a.ID); err != nil { log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err) p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) } + // Keep created_at field from previous instance of the album if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil { // Silently ignore when the previous album is not found diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 6f56f6a52..a6c0e261e 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -3,6 +3,7 @@ package scanner import ( "context" "fmt" + "sync" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -31,14 +32,21 @@ type missingTracks struct { // 4. Updates the database with the new locations of the matched files and removes the old entries. // 5. Logs the results and finalizes the phase by reporting the total number of matched files. type phaseMissingTracks struct { - ctx context.Context - ds model.DataStore - totalMatched atomic.Uint32 - state *scanState + ctx context.Context + ds model.DataStore + totalMatched atomic.Uint32 + state *scanState + processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments + annotationMutex sync.RWMutex // Protects processedAlbumAnnotations } func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks { - return &phaseMissingTracks{ctx: ctx, ds: ds, state: state} + return &phaseMissingTracks{ + ctx: ctx, + ds: ds, + state: state, + processedAlbumAnnotations: make(map[string]bool), + } } func (p *phaseMissingTracks) description() string { @@ -52,17 +60,15 @@ func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] { func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { count := 0 var putIfMatched = func(mt missingTracks) { - if mt.pid != "" && len(mt.matched) > 0 { - log.Trace(p.ctx, "Scanner: Found missing and matching tracks", "pid", mt.pid, "missing", len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name) + if mt.pid != "" && len(mt.missing) > 0 { + log.Trace(p.ctx, "Scanner: Found missing tracks", "pid", mt.pid, "missing", "title", mt.missing[0].Title, + len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name, + ) count++ put(&mt) } } - libs, err := p.ds.Library(p.ctx).GetAll() - if err != nil { - return fmt.Errorf("loading libraries: %w", err) - } - for _, lib := range libs { + for _, lib := range p.state.libraries { if lib.LastScanStartedAt.IsZero() { continue } @@ -104,10 +110,13 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] { return []ppl.Stage[*missingTracks]{ ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")), + ppl.NewStage(p.processCrossLibraryMoves, ppl.Name("process cross-library moves")), } } func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) { + hasMatches := false + for _, ms := range in.missing { var exactMatch model.MediaFile var equivalentMatch model.MediaFile @@ -132,6 +141,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr return nil, err } p.totalMatched.Add(1) + hasMatches = true continue } @@ -145,6 +155,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr return nil, err } p.totalMatched.Add(1) + hasMatches = true continue } @@ -157,23 +168,141 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr return nil, err } p.totalMatched.Add(1) + hasMatches = true } } + + // If any matches were found in this missingTracks group, return nil + // This signals the next stage to skip processing this group + if hasMatches { + return nil, nil + } + + // If no matches found, pass through to next stage return in, nil } -func (p *phaseMissingTracks) moveMatched(mt, ms model.MediaFile) error { +// processCrossLibraryMoves processes files that weren't matched within their library +// and attempts to find matches in other libraries +func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) { + // Skip if input is nil (meaning previous stage found matches) + if in == nil { + return nil, nil + } + + log.Debug(p.ctx, "Scanner: Processing cross-library moves", "pid", in.pid, "missing", len(in.missing), "lib", in.lib.Name) + + for _, missing := range in.missing { + found, err := p.findCrossLibraryMatch(missing) + if err != nil { + log.Error(p.ctx, "Scanner: Error searching for cross-library matches", "missing", missing.Path, "lib", in.lib.Name, err) + continue + } + + if found.ID != "" { + log.Debug(p.ctx, "Scanner: Found cross-library moved track", "missing", missing.Path, "movedTo", found.Path, "fromLib", in.lib.Name, "toLib", found.LibraryName) + err := p.moveMatched(found, missing) + if err != nil { + log.Error(p.ctx, "Scanner: Error moving cross-library track", "missing", missing.Path, "movedTo", found.Path, err) + continue + } + p.totalMatched.Add(1) + } + } + + return in, nil +} + +// findCrossLibraryMatch searches for a missing file in other libraries using two-tier matching +func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) { + // First tier: Search by MusicBrainz Track ID if available + if missing.MbzReleaseTrackID != "" { + matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByMBZTrackID(missing, missing.CreatedAt) + if err != nil { + log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err) + } else { + // Apply the same matching logic as within-library matching + for _, match := range matches { + if missing.Equals(match) { + return match, nil // Exact match found + } + } + + // If only one match and it's equivalent, use it + if len(matches) == 1 && missing.IsEquivalent(matches[0]) { + return matches[0], nil + } + } + } + + // Second tier: Search by intrinsic properties (title, size, suffix, etc.) + matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByProperties(missing, missing.CreatedAt) + if err != nil { + log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err) + return model.MediaFile{}, err + } + + // Apply the same matching logic as within-library matching + for _, match := range matches { + if missing.Equals(match) { + return match, nil // Exact match found + } + } + + // If only one match and it's equivalent, use it + if len(matches) == 1 && missing.IsEquivalent(matches[0]) { + return matches[0], nil + } + + return model.MediaFile{}, nil +} + +func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error { return p.ds.WithTx(func(tx model.DataStore) error { - discardedID := mt.ID - mt.ID = ms.ID - err := tx.MediaFile(p.ctx).Put(&mt) + discardedID := target.ID + oldAlbumID := missing.AlbumID + newAlbumID := target.AlbumID + + // Update the target media file with the missing file's ID. This effectively "moves" the track + // to the new location while keeping its annotations and references intact. + target.ID = missing.ID + err := tx.MediaFile(p.ctx).Put(&target) if err != nil { return fmt.Errorf("update matched track: %w", err) } + + // Discard the new mediafile row (the one that was moved to) err = tx.MediaFile(p.ctx).Delete(discardedID) if err != nil { return fmt.Errorf("delete discarded track: %w", err) } + + // Handle album annotation reassignment if AlbumID changed + if oldAlbumID != newAlbumID { + // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target + p.annotationMutex.RLock() + alreadyProcessed := p.processedAlbumAnnotations[newAlbumID] + p.annotationMutex.RUnlock() + + if !alreadyProcessed { + p.annotationMutex.Lock() + // Double-check pattern to avoid race conditions + if !p.processedAlbumAnnotations[newAlbumID] { + // Reassign direct album annotations (starred, rating) + log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) + if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) + } + + // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here + p.processedAlbumAnnotations[newAlbumID] = true + } + p.annotationMutex.Unlock() + } else { + log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) + } + } + p.state.changesDetected.Store(true) return nil }) diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index 5dd6cc679..e709004c9 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -28,7 +28,9 @@ var _ = Describe("phaseMissingTracks", func() { lr = &tests.MockLibraryRepo{} lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}}) ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr} - state = &scanState{} + state = &scanState{ + libraries: model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } phase = createPhaseMissingTracks(ctx, state, ds) }) @@ -68,12 +70,31 @@ var _ = Describe("phaseMissingTracks", func() { err := phase.produce(put) Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(1)) - Expect(produced[0].pid).To(Equal("A")) - Expect(produced[0].missing).To(HaveLen(1)) - Expect(produced[0].matched).To(HaveLen(1)) + Expect(produced).To(HaveLen(2)) + // PID A should have both missing and matched tracks + var pidA *missingTracks + for _, p := range produced { + if p.pid == "A" { + pidA = p + break + } + } + Expect(pidA).ToNot(BeNil()) + Expect(pidA.missing).To(HaveLen(1)) + Expect(pidA.matched).To(HaveLen(1)) + // PID B should have only missing tracks + var pidB *missingTracks + for _, p := range produced { + if p.pid == "B" { + pidB = p + break + } + } + Expect(pidB).ToNot(BeNil()) + Expect(pidB.missing).To(HaveLen(1)) + Expect(pidB.matched).To(HaveLen(0)) }) - It("should not call put if there are no matches for any missing tracks", func() { + It("should call put for any missing tracks even without matches", func() { mr.SetData(model.MediaFiles{ {ID: "1", PID: "A", Missing: true, LibraryID: 1}, {ID: "2", PID: "B", Missing: true, LibraryID: 1}, @@ -82,7 +103,22 @@ var _ = Describe("phaseMissingTracks", func() { err := phase.produce(put) Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(BeZero()) + Expect(produced).To(HaveLen(2)) + // Both PID A and PID B should be produced even without matches + var pidA, pidB *missingTracks + for _, p := range produced { + if p.pid == "A" { + pidA = p + } else if p.pid == "B" { + pidB = p + } + } + Expect(pidA).ToNot(BeNil()) + Expect(pidA.missing).To(HaveLen(1)) + Expect(pidA.matched).To(HaveLen(0)) + Expect(pidB).ToNot(BeNil()) + Expect(pidB.missing).To(HaveLen(1)) + Expect(pidB.matched).To(HaveLen(0)) }) }) }) @@ -286,4 +322,448 @@ var _ = Describe("phaseMissingTracks", func() { }) }) }) + + Describe("processCrossLibraryMoves", func() { + It("should skip processing if input is nil", func() { + result, err := phase.processCrossLibraryMoves(nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("should process cross-library moves using MusicBrainz Track ID", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing1", + LibraryID: 1, + MbzReleaseTrackID: "mbz-track-123", + Title: "Test Track", + Size: 1000, + Suffix: "mp3", + Path: "/lib1/track.mp3", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + movedTrack := model.MediaFile{ + ID: "moved1", + LibraryID: 2, + MbzReleaseTrackID: "mbz-track-123", + Title: "Test Track", + Size: 1000, + Suffix: "mp3", + Path: "/lib2/track.mp3", + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&movedTrack) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // Verify the move was performed + updatedTrack, _ := ds.MediaFile(ctx).Get("missing1") + Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3")) + Expect(updatedTrack.LibraryID).To(Equal(2)) + }) + + It("should fall back to intrinsic properties when MBZ Track ID is empty", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing2", + LibraryID: 1, + MbzReleaseTrackID: "", + Title: "Test Track 2", + Size: 2000, + Suffix: "flac", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib1/track2.flac", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + movedTrack := model.MediaFile{ + ID: "moved2", + LibraryID: 2, + MbzReleaseTrackID: "", + Title: "Test Track 2", + Size: 2000, + Suffix: "flac", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib2/track2.flac", + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&movedTrack) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // Verify the move was performed + updatedTrack, _ := ds.MediaFile(ctx).Get("missing2") + Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac")) + Expect(updatedTrack.LibraryID).To(Equal(2)) + }) + + It("should not match files in the same library", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing3", + LibraryID: 1, + MbzReleaseTrackID: "mbz-track-456", + Title: "Test Track 3", + Size: 3000, + Suffix: "mp3", + Path: "/lib1/track3.mp3", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + sameLibTrack := model.MediaFile{ + ID: "same1", + LibraryID: 1, // Same library + MbzReleaseTrackID: "mbz-track-456", + Title: "Test Track 3", + Size: 3000, + Suffix: "mp3", + Path: "/lib1/other/track3.mp3", + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&sameLibTrack) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + + It("should prioritize MBZ Track ID over intrinsic properties", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing4", + LibraryID: 1, + MbzReleaseTrackID: "mbz-track-789", + Title: "Test Track 4", + Size: 4000, + Suffix: "mp3", + Path: "/lib1/track4.mp3", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + // Track with same MBZ ID + mbzTrack := model.MediaFile{ + ID: "mbz1", + LibraryID: 2, + MbzReleaseTrackID: "mbz-track-789", + Title: "Test Track 4", + Size: 4000, + Suffix: "mp3", + Path: "/lib2/track4.mp3", + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + // Track with same intrinsic properties but no MBZ ID + intrinsicTrack := model.MediaFile{ + ID: "intrinsic1", + LibraryID: 3, + MbzReleaseTrackID: "", + Title: "Test Track 4", + Size: 4000, + Suffix: "mp3", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib3/track4.mp3", + Missing: false, + CreatedAt: scanStartTime.Add(-5 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&mbzTrack) + _ = ds.MediaFile(ctx).Put(&intrinsicTrack) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // Verify the MBZ track was chosen (not the intrinsic one) + updatedTrack, _ := ds.MediaFile(ctx).Get("missing4") + Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3")) + Expect(updatedTrack.LibraryID).To(Equal(2)) + }) + + It("should handle equivalent matches correctly", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing5", + LibraryID: 1, + MbzReleaseTrackID: "", + Title: "Test Track 5", + Size: 5000, + Suffix: "mp3", + Path: "/lib1/path/track5.mp3", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + // Equivalent match (same filename, different directory) + equivalentTrack := model.MediaFile{ + ID: "equiv1", + LibraryID: 2, + MbzReleaseTrackID: "", + Title: "Test Track 5", + Size: 5000, + Suffix: "mp3", + Path: "/lib2/different/track5.mp3", + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&equivalentTrack) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // Verify the equivalent match was accepted + updatedTrack, _ := ds.MediaFile(ctx).Get("missing5") + Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3")) + Expect(updatedTrack.LibraryID).To(Equal(2)) + }) + + It("should skip matching when multiple matches are found but none are exact", func() { + scanStartTime := time.Now().Add(-1 * time.Hour) + missingTrack := model.MediaFile{ + ID: "missing6", + LibraryID: 1, + MbzReleaseTrackID: "", + Title: "Test Track 6", + Size: 6000, + Suffix: "mp3", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib1/track6.mp3", + Missing: true, + CreatedAt: scanStartTime.Add(-30 * time.Minute), + } + + // Multiple matches with different metadata (not exact matches) + match1 := model.MediaFile{ + ID: "match1", + LibraryID: 2, + MbzReleaseTrackID: "", + Title: "Test Track 6", + Size: 6000, + Suffix: "mp3", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib2/different_track.mp3", + Artist: "Different Artist", // This makes it non-exact + Missing: false, + CreatedAt: scanStartTime.Add(-10 * time.Minute), + } + + match2 := model.MediaFile{ + ID: "match2", + LibraryID: 3, + MbzReleaseTrackID: "", + Title: "Test Track 6", + Size: 6000, + Suffix: "mp3", + DiscNumber: 1, + TrackNumber: 1, + Album: "Test Album", + Path: "/lib3/another_track.mp3", + Artist: "Another Artist", // This makes it non-exact + Missing: false, + CreatedAt: scanStartTime.Add(-5 * time.Minute), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&match1) + _ = ds.MediaFile(ctx).Put(&match2) + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) + Expect(state.changesDetected.Load()).To(BeFalse()) + + // Verify no move was performed + unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6") + Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3")) + Expect(unchangedTrack.LibraryID).To(Equal(1)) + }) + + It("should handle errors gracefully", func() { + // Set up mock to return error + mr.Err = true + + missingTrack := model.MediaFile{ + ID: "missing7", + LibraryID: 1, + MbzReleaseTrackID: "mbz-track-error", + Title: "Test Track 7", + Size: 7000, + Suffix: "mp3", + Path: "/lib1/track7.mp3", + Missing: true, + CreatedAt: time.Now().Add(-30 * time.Minute), + } + + in := &missingTracks{ + lib: model.Library{ID: 1, Name: "Library 1"}, + missing: []model.MediaFile{missingTrack}, + } + + // Should not fail completely, just skip the problematic file + result, err := phase.processCrossLibraryMoves(in) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(in)) + Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + }) + + Describe("Album Annotation Reassignment", func() { + var ( + albumRepo *tests.MockAlbumRepo + missingTrack model.MediaFile + matchedTrack model.MediaFile + oldAlbumID string + newAlbumID string + ) + + BeforeEach(func() { + albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + albumRepo.ReassignAnnotationCalls = make(map[string]string) + + oldAlbumID = "old-album-id" + newAlbumID = "new-album-id" + + missingTrack = model.MediaFile{ + ID: "missing-track-id", + PID: "same-pid", + Path: "old/path.mp3", + AlbumID: oldAlbumID, + LibraryID: 1, + Missing: true, + Annotations: model.Annotations{ + PlayCount: 5, + Rating: 4, + Starred: true, + }, + } + + matchedTrack = model.MediaFile{ + ID: "matched-track-id", + PID: "same-pid", + Path: "new/path.mp3", + AlbumID: newAlbumID, + LibraryID: 2, // Different library + Missing: false, + Annotations: model.Annotations{ + PlayCount: 2, + Rating: 3, + Starred: false, + }, + } + + // Store both tracks in the database + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + }) + + When("album ID changes during cross-library move", func() { + It("should reassign album annotations when AlbumID changes", func() { + err := phase.moveMatched(matchedTrack, missingTrack) + Expect(err).ToNot(HaveOccurred()) + + // Verify that ReassignAnnotation was called + Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID)) + }) + + It("should not reassign annotations when AlbumID is the same", func() { + missingTrack.AlbumID = newAlbumID // Same album + + err := phase.moveMatched(matchedTrack, missingTrack) + Expect(err).ToNot(HaveOccurred()) + + // Verify that ReassignAnnotation was NOT called + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + }) + + When("error handling", func() { + It("should handle ReassignAnnotation errors gracefully", func() { + // Make the album repo return an error + albumRepo.SetError(true) + + // The move should still succeed even if annotation reassignment fails + err := phase.moveMatched(matchedTrack, missingTrack) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the track was still moved (ID should be updated) + movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + }) + }) + }) }) diff --git a/scanner/scanner.go b/scanner/scanner.go index 7ddc78b17..04a5c2456 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -28,6 +28,7 @@ type scanState struct { progress chan<- *ProgressInfo fullScan bool changesDetected atomic.Bool + libraries model.Libraries // Store libraries list for consistency across phases } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -63,6 +64,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) return } + state.libraries = libs log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs)) @@ -111,7 +113,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< s.runRefreshStats(ctx, &state), // Update last_scan_completed_at for all libraries - s.runUpdateLibraries(ctx, libs, &state), + s.runUpdateLibraries(ctx, &state), // Optimize DB s.runOptimize(ctx), @@ -186,11 +188,11 @@ func (s *scannerImpl) runOptimize(ctx context.Context) func() error { } } -func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Libraries, state *scanState) func() error { +func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() return s.ds.WithTx(func(tx model.DataStore) error { - for _, lib := range libs { + for _, lib := range state.libraries { err := tx.Library(ctx).ScanEnd(lib.ID) if err != nil { log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err) @@ -216,7 +218,7 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Librari log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) } } - log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(libs)) + log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(state.libraries)) return nil }, "scanner: update libraries") } diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go new file mode 100644 index 000000000..f27ad52fc --- /dev/null +++ b/scanner/scanner_multilibrary_test.go @@ -0,0 +1,831 @@ +package scanner_test + +import ( + "context" + "errors" + "path/filepath" + "testing/fstest" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Scanner - Multi-Library", Ordered, func() { + var ctx context.Context + var lib1, lib2 model.Library + var ds *tests.MockDataStore + var s scanner.Scanner + + createFS := func(path string, files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register(path, &fs) + return fs + } + + BeforeAll(func() { + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) + tmpDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL") + log.Warn("Using DB at " + conf.Server.DbPath) + db.Db().SetMaxOpenConns(1) + }) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevExternalScanner = false + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Create the admin user in the database to match the context + adminUser := model.User{ + ID: "123", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + + // Create two test libraries (let DB auto-assign IDs) + lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} + lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"} + Expect(ds.Library(ctx).Put(&lib1)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + }) + + runScanner := func(ctx context.Context, fullScan bool) error { + _, err := s.ScanAll(ctx, fullScan) + return err + } + + Context("Two Libraries with Different Content", func() { + BeforeEach(func() { + // Rock library content + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + zeppelin := template(_t{"albumartist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), + "Led Zeppelin/IV/01 - Black Dog.mp3": zeppelin(track(1, "Black Dog")), + "Led Zeppelin/IV/02 - Rock and Roll.mp3": zeppelin(track(2, "Rock and Roll")), + }) + + // Jazz library content + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"}) + + _ = createFS("jazz", fstest.MapFS{ + "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), + "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), + "John Coltrane/Giant Steps/01 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), + "John Coltrane/Giant Steps/02 - Cousin Mary.mp3": coltrane(track(2, "Cousin Mary")), + }) + }) + + When("scanning both libraries", func() { + It("should import files with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library media files + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(4)) + + rockTitles := slice.Map(rockFiles, func(f model.MediaFile) string { return f.Title }) + Expect(rockTitles).To(ContainElements("Come Together", "Something", "Black Dog", "Rock and Roll")) + + // Verify all rock files have correct library_id + for _, mf := range rockFiles { + Expect(mf.LibraryID).To(Equal(lib1.ID), "Rock file %s should have library_id %d", mf.Title, lib1.ID) + } + + // Check Jazz library media files + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(4)) + + jazzTitles := slice.Map(jazzFiles, func(f model.MediaFile) string { return f.Title }) + Expect(jazzTitles).To(ContainElements("So What", "Freddie Freeloader", "Giant Steps", "Cousin Mary")) + + // Verify all jazz files have correct library_id + for _, mf := range jazzFiles { + Expect(mf.LibraryID).To(Equal(lib2.ID), "Jazz file %s should have library_id %d", mf.Title, lib2.ID) + } + }) + + It("should create albums with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library albums + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockAlbums).To(HaveLen(2)) + Expect(rockAlbums[0].Name).To(Equal("Abbey Road")) + Expect(rockAlbums[0].LibraryID).To(Equal(lib1.ID)) + Expect(rockAlbums[0].SongCount).To(Equal(2)) + Expect(rockAlbums[1].Name).To(Equal("IV")) + Expect(rockAlbums[1].LibraryID).To(Equal(lib1.ID)) + Expect(rockAlbums[1].SongCount).To(Equal(2)) + + // Check Jazz library albums + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzAlbums).To(HaveLen(2)) + Expect(jazzAlbums[0].Name).To(Equal("Giant Steps")) + Expect(jazzAlbums[0].LibraryID).To(Equal(lib2.ID)) + Expect(jazzAlbums[0].SongCount).To(Equal(2)) + Expect(jazzAlbums[1].Name).To(Equal("Kind of Blue")) + Expect(jazzAlbums[1].LibraryID).To(Equal(lib2.ID)) + Expect(jazzAlbums[1].SongCount).To(Equal(2)) + }) + + It("should create folders with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library folders + rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFolders).To(HaveLen(5)) // ., The Beatles, Led Zeppelin, Abbey Road, IV + + for _, folder := range rockFolders { + Expect(folder.LibraryID).To(Equal(lib1.ID), "Rock folder %s should have library_id %d", folder.Name, lib1.ID) + } + + // Check Jazz library folders + jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFolders).To(HaveLen(5)) // ., Miles Davis, John Coltrane, Kind of Blue, Giant Steps + + for _, folder := range jazzFolders { + Expect(folder.LibraryID).To(Equal(lib2.ID), "Jazz folder %s should have library_id %d", folder.Name, lib2.ID) + } + }) + + It("should create library-artist associations correctly", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check library-artist associations + + // Get all artists and check library associations + allArtists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + + rockArtistNames := []string{} + jazzArtistNames := []string{} + + for _, artist := range allArtists { + // Check if artist is associated with rock library + var count int64 + err := db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib1.ID, artist.ID, + ).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + if count > 0 { + rockArtistNames = append(rockArtistNames, artist.Name) + } + + // Check if artist is associated with jazz library + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib2.ID, artist.ID, + ).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + if count > 0 { + jazzArtistNames = append(jazzArtistNames, artist.Name) + } + } + + Expect(rockArtistNames).To(ContainElements("The Beatles", "Led Zeppelin")) + Expect(jazzArtistNames).To(ContainElements("Miles Davis", "John Coltrane")) + + // Artists should not be shared between libraries (except [Unknown Artist]) + for _, name := range rockArtistNames { + if name != "[Unknown Artist]" { + Expect(jazzArtistNames).ToNot(ContainElement(name)) + } + } + }) + + It("should update library statistics correctly", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library stats + rockLib, err := ds.Library(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(4)) + Expect(rockLib.TotalAlbums).To(Equal(2)) + + Expect(rockLib.TotalArtists).To(Equal(3)) // The Beatles, Led Zeppelin, [Unknown Artist] + Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files) + + // Check Jazz library stats + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(4)) + Expect(jazzLib.TotalAlbums).To(Equal(2)) + Expect(jazzLib.TotalArtists).To(Equal(3)) // Miles Davis, John Coltrane, [Unknown Artist] + Expect(jazzLib.TotalFolders).To(Equal(2)) // Kind of Blue, Giant Steps (only folders with audio files) + }) + }) + + When("libraries have different content", func() { + It("should maintain separate statistics per library", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify rock library stats + rockLib, err := ds.Library(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(4)) + Expect(rockLib.TotalAlbums).To(Equal(2)) + + // Verify jazz library stats + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(4)) + Expect(jazzLib.TotalAlbums).To(Equal(2)) + + // Verify that libraries don't interfere with each other + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(4)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(4)) + }) + }) + + When("verifying library isolation", func() { + It("should keep library data completely separate", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify that rock library only contains rock content + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + rockAlbumNames := slice.Map(rockAlbums, func(a model.Album) string { return a.Name }) + Expect(rockAlbumNames).To(ContainElements("Abbey Road", "IV")) + Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps")) + + // Verify that jazz library only contains jazz content + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + jazzAlbumNames := slice.Map(jazzAlbums, func(a model.Album) string { return a.Name }) + Expect(jazzAlbumNames).To(ContainElements("Kind of Blue", "Giant Steps")) + Expect(jazzAlbumNames).ToNot(ContainElements("Abbey Road", "IV")) + }) + }) + + When("same artist appears in different libraries", func() { + It("should associate artist with both libraries correctly", func() { + // Create libraries with Jeff Beck albums in both + jeffRock := template(_t{"albumartist": "Jeff Beck", "album": "Truth", "year": 1968, "genre": "Rock"}) + jeffJazz := template(_t{"albumartist": "Jeff Beck", "album": "Blow by Blow", "year": 1975, "genre": "Jazz"}) + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + + // Create rock library with Jeff Beck's Truth album + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), + "Jeff Beck/Truth/01 - Beck's Bolero.mp3": jeffRock(track(1, "Beck's Bolero")), + "Jeff Beck/Truth/02 - Ol' Man River.mp3": jeffRock(track(2, "Ol' Man River")), + }) + + // Create jazz library with Jeff Beck's Blow by Blow album + _ = createFS("jazz", fstest.MapFS{ + "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), + "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), + "Jeff Beck/Blow by Blow/01 - You Know What I Mean.mp3": jeffJazz(track(1, "You Know What I Mean")), + "Jeff Beck/Blow by Blow/02 - She's a Woman.mp3": jeffJazz(track(2, "She's a Woman")), + }) + + Expect(runScanner(ctx, true)).To(Succeed()) + + // Jeff Beck should be associated with both libraries + var rockCount, jazzCount int64 + + // Get Jeff Beck artist ID + jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jeffArtists).To(HaveLen(1)) + jeffID := jeffArtists[0].ID + + // Check rock library association + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib1.ID, jeffID, + ).Scan(&rockCount) + Expect(err).ToNot(HaveOccurred()) + Expect(rockCount).To(Equal(int64(1))) + + // Check jazz library association + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib2.ID, jeffID, + ).Scan(&jazzCount) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzCount).To(Equal(int64(1))) + + // Verify Jeff Beck albums are in correct libraries + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockAlbums).To(HaveLen(1)) + Expect(rockAlbums[0].Name).To(Equal("Truth")) + + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzAlbums).To(HaveLen(1)) + Expect(jazzAlbums[0].Name).To(Equal("Blow by Blow")) + }) + }) + }) + + Context("Incremental Scan Behavior", func() { + BeforeEach(func() { + // Start with minimal content in both libraries + rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"}) + jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"}) + + createFS("rock", fstest.MapFS{ + "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")), + }) + + createFS("jazz", fstest.MapFS{ + "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")), + }) + }) + + It("should handle incremental scans per library correctly", func() { + // Initial full scan + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify initial state + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + + // Incremental scan should not duplicate existing files + Expect(runScanner(ctx, false)).To(Succeed()) + + // Verify counts remain the same + rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + }) + }) + + Context("Missing Files Handling", func() { + var rockFS storagetest.FakeFS + + BeforeEach(func() { + rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) + + rockFS = createFS("rock", fstest.MapFS{ + "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), + "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")), + }) + + createFS("jazz", fstest.MapFS{ + "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": template(_t{ + "albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz", + })(track(1, "Chameleon")), + }) + }) + + It("should mark missing files correctly per library", func() { + // Initial scan + Expect(runScanner(ctx, true)).To(Succeed()) + + // Remove one file from rock library only + rockFS.Remove("AC-DC/Back in Black/02 - Shoot to Thrill.mp3") + + // Rescan + Expect(runScanner(ctx, false)).To(Succeed()) + + // Check that only the rock library file is marked as missing + missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib1.ID}, + squirrel.Eq{"missing": true}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(missingRockFiles).To(HaveLen(1)) + Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill")) + + // Check that jazz library files are not affected + missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib2.ID}, + squirrel.Eq{"missing": true}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(missingJazzFiles).To(HaveLen(0)) + + // Verify non-missing files + presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib1.ID}, + squirrel.Eq{"missing": false}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(presentRockFiles).To(HaveLen(1)) + Expect(presentRockFiles[0].Title).To(Equal("Hells Bells")) + }) + }) + + Context("Error Handling - Multi-Library", func() { + Context("Filesystem errors affecting one library", func() { + var rockFS storagetest.FakeFS + + BeforeEach(func() { + // Set up content for both libraries + rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) + jazz := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + + rockFS = createFS("rock", fstest.MapFS{ + "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), + "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")), + }) + + createFS("jazz", fstest.MapFS{ + "Miles Davis/Kind of Blue/01 - So What.mp3": jazz(track(1, "So What")), + "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": jazz(track(2, "Freddie Freeloader")), + }) + }) + + It("should not affect scanning of other libraries", func() { + // Inject filesystem read error in rock library only + rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("filesystem read error")) + + // Scan should succeed overall and return warnings + warnings, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors") + + // Jazz library should have been scanned successfully + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(2)) + Expect(jazzFiles[0].Title).To(BeElementOf("So What", "Freddie Freeloader")) + Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader")) + + // Rock library may have partial content (depending on scanner implementation) + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + // No specific expectation - some files may have been imported despite errors + _ = rockFiles + + // Verify jazz library stats are correct + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(2)) + + // Error should be empty (warnings don't count as scan errors) + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + + It("should continue with warnings for affected library", func() { + // Inject read errors on multiple files in rock library + rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("read error 1")) + rockFS.SetError("AC-DC/Back in Black/02 - Shoot to Thrill.mp3", errors.New("read error 2")) + + // Scan should complete with warnings for multiple filesystem errors + warnings, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors") + + // Jazz library should be completely unaffected + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(2)) + + // Jazz library statistics should be accurate + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(2)) + Expect(jazzLib.TotalAlbums).To(Equal(1)) + + // Error should be empty (warnings don't count as scan errors) + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + }) + + Context("Database errors during multi-library scanning", func() { + BeforeEach(func() { + // Set up content for both libraries + rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"}) + jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"}) + + createFS("rock", fstest.MapFS{ + "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")), + }) + + createFS("jazz", fstest.MapFS{ + "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")), + }) + }) + + It("should propagate database errors and stop scanning", func() { + // Install mock repo that injects DB error + mfRepo := &mockMediaFileRepo{ + MediaFileRepository: ds.RealDS.MediaFile(ctx), + GetMissingAndMatchingError: errors.New("database connection failed"), + } + ds.MockedMediaFile = mfRepo + + // Scan should return the database error + Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed"))) + + // Error should be recorded in scanner properties + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(ContainSubstring("database connection failed")) + }) + + It("should preserve error information in scanner properties", func() { + // Install mock repo that injects DB error + mfRepo := &mockMediaFileRepo{ + MediaFileRepository: ds.RealDS.MediaFile(ctx), + GetMissingAndMatchingError: errors.New("critical database error"), + } + ds.MockedMediaFile = mfRepo + + // Attempt scan (should fail) + Expect(runScanner(ctx, false)).To(HaveOccurred()) + + // Check that error is recorded in scanner properties + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(ContainSubstring("critical database error")) + + // Scan type should still be recorded + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + Expect(scanType).To(BeElementOf("incremental", "quick")) + }) + }) + + Context("Mixed error scenarios", func() { + var rockFS storagetest.FakeFS + + BeforeEach(func() { + // Set up rock library with filesystem that can error + rock := template(_t{"albumartist": "Metallica", "album": "Master of Puppets", "year": 1986, "genre": "Metal"}) + rockFS = createFS("rock", fstest.MapFS{ + "Metallica/Master of Puppets/01 - Battery.mp3": rock(track(1, "Battery")), + "Metallica/Master of Puppets/02 - Master of Puppets.mp3": rock(track(2, "Master of Puppets")), + }) + + // Set up jazz library normally + jazz := template(_t{"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz"}) + createFS("jazz", fstest.MapFS{ + "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": jazz(track(1, "Chameleon")), + }) + }) + + It("should handle filesystem errors in one library while other succeeds", func() { + // Inject filesystem error in rock library + rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("disk read error")) + + // Scan should complete with warnings (not hard error) + warnings, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error") + + // Jazz library should scan completely successfully + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + Expect(jazzFiles[0].Title).To(Equal("Chameleon")) + + // Jazz library statistics should be accurate + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(1)) + Expect(jazzLib.TotalAlbums).To(Equal(1)) + + // Rock library may have partial content (depending on scanner implementation) + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + // No specific expectation - some files may have been imported despite errors + _ = rockFiles + + // Error should be empty (warnings don't count as scan errors) + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + + It("should handle partial failures gracefully", func() { + // Create a scenario where rock has filesystem issues and jazz has normal content + rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("file corruption")) + + // Do an initial scan with filesystem error + warnings, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption") + + // Verify that the working parts completed successfully + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + + // Scanner properties should reflect successful completion despite warnings + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + Expect(scanType).To(Equal("full")) + + // Start time should be recorded + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + Expect(startTimeStr).ToNot(BeEmpty()) + + // Error should be empty (warnings don't count as scan errors) + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + }) + + Context("Error recovery in multi-library context", func() { + It("should recover from previous library-specific errors", func() { + // Set up initial content + rock := template(_t{"albumartist": "Iron Maiden", "album": "The Number of the Beast", "year": 1982, "genre": "Metal"}) + jazz := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"}) + + rockFS := createFS("rock", fstest.MapFS{ + "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")), + }) + + createFS("jazz", fstest.MapFS{ + "John Coltrane/Giant Steps/01 - Giant Steps.mp3": jazz(track(1, "Giant Steps")), + }) + + // First scan with filesystem error in rock + rockFS.SetError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3", errors.New("temporary disk error")) + warnings, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) // Should succeed with warnings + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error") + + // Clear the error and add more content - recreate the filesystem completely + rockFS.ClearError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3") + + // Create a new filesystem with both files + createFS("rock", fstest.MapFS{ + "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")), + "Iron Maiden/The Number of the Beast/02 - Children of the Damned.mp3": rock(track(2, "Children of the Damned")), + }) + + // Second scan should recover and import all rock content + warnings, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error") + + // Verify both libraries now have content (at least jazz should work) + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + // The scanner should recover and import both rock files + Expect(len(rockFiles)).To(Equal(2)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + + // Both libraries should have correct content counts + rockLib, err := ds.Library(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(2)) + + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(1)) + + // Error should be empty (successful recovery) + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + }) + }) + + Context("Scanner Properties", func() { + It("should persist last scan type, start time and error properties", func() { + // trivial FS setup + rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) + _ = createFS("rock", fstest.MapFS{ + "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), + }) + + // Run a full scan + Expect(runScanner(ctx, true)).To(Succeed()) + + // Validate properties + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + Expect(scanType).To(Equal("full")) + + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + Expect(startTimeStr).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, startTimeStr) + Expect(err).ToNot(HaveOccurred()) + + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + }) +}) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 6bb74997f..e7e354f21 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -58,12 +58,14 @@ var _ = Describe("Scanner", Ordered, func() { }) BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" // Set to match test library path + conf.Server.DevExternalScanner = false + db.Init(ctx) DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) - DeferCleanup(configtest.SetupConfig()) - conf.Server.DevExternalScanner = false ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} mfRepo = &mockMediaFileRepo{ @@ -71,6 +73,16 @@ var _ = Describe("Scanner", Ordered, func() { } ds.MockedMediaFile = mfRepo + // Create the admin user in the database to match the context + adminUser := model.User{ + ID: "123", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance()) diff --git a/scanner/watcher.go b/scanner/watcher.go index bf4f7f9d0..37cfb5e22 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,42 +5,67 @@ import ( "fmt" "io/fs" "path/filepath" + "sync" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/singleton" ) type Watcher interface { Run(ctx context.Context) error + Watch(ctx context.Context, lib *model.Library) error + StopWatching(ctx context.Context, libraryID int) error } type watcher struct { - ds model.DataStore - scanner Scanner - triggerWait time.Duration + mainCtx context.Context + ds model.DataStore + scanner Scanner + triggerWait time.Duration + watcherNotify chan model.Library + libraryWatchers map[int]*libraryWatcherInstance + mu sync.RWMutex } -func NewWatcher(ds model.DataStore, s Scanner) Watcher { - return &watcher{ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait} +type libraryWatcherInstance struct { + library *model.Library + cancel context.CancelFunc +} + +// GetWatcher returns the watcher singleton +func GetWatcher(ds model.DataStore, s Scanner) Watcher { + return singleton.GetInstance(func() *watcher { + return &watcher{ + ds: ds, + scanner: s, + triggerWait: conf.Server.Scanner.WatcherWait, + watcherNotify: make(chan model.Library, 1), + libraryWatchers: make(map[int]*libraryWatcherInstance), + } + }) } func (w *watcher) Run(ctx context.Context) error { + // Keep the main context to be used in all watchers added later + w.mainCtx = ctx + + // Start watchers for all existing libraries libs, err := w.ds.Library(ctx).GetAll() if err != nil { return fmt.Errorf("getting libraries: %w", err) } - watcherChan := make(chan struct{}) - defer close(watcherChan) - - // Start a watcher for each library for _, lib := range libs { - go watchLib(ctx, lib, watcherChan) + if err := w.Watch(ctx, &lib); err != nil { + log.Warn(ctx, "Failed to start watcher for existing library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } } + // Main scan triggering loop trigger := time.NewTimer(w.triggerWait) trigger.Stop() waiting := false @@ -68,61 +93,137 @@ func (w *watcher) Run(ctx context.Context) error { } }() case <-ctx.Done(): + // Stop all library watchers + w.mu.Lock() + for libraryID, instance := range w.libraryWatchers { + log.Debug(ctx, "Stopping library watcher due to context cancellation", "libraryID", libraryID) + instance.cancel() + } + w.libraryWatchers = make(map[int]*libraryWatcherInstance) + w.mu.Unlock() return nil - case <-watcherChan: + case lib := <-w.watcherNotify: if !waiting { - log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan") + log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", + "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) waiting = true } - trigger.Reset(w.triggerWait) } } } -func watchLib(ctx context.Context, lib model.Library, watchChan chan struct{}) { +func (w *watcher) Watch(ctx context.Context, lib *model.Library) error { + w.mu.Lock() + defer w.mu.Unlock() + + // Stop existing watcher if any + if existingInstance, exists := w.libraryWatchers[lib.ID]; exists { + log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name) + existingInstance.cancel() + } + + // Start new watcher + watcherCtx, cancel := context.WithCancel(w.mainCtx) + instance := &libraryWatcherInstance{ + library: lib, + cancel: cancel, + } + + w.libraryWatchers[lib.ID] = instance + + // Start watching in a goroutine + go func() { + defer func() { + w.mu.Lock() + if currentInstance, exists := w.libraryWatchers[lib.ID]; exists && currentInstance == instance { + delete(w.libraryWatchers, lib.ID) + } + w.mu.Unlock() + }() + + err := w.watchLibrary(watcherCtx, lib) + if err != nil && watcherCtx.Err() == nil { // Only log error if not due to cancellation + log.Error(ctx, "Watcher error", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } + }() + + log.Info(ctx, "Started watcher for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) + return nil +} + +func (w *watcher) StopWatching(ctx context.Context, libraryID int) error { + w.mu.Lock() + defer w.mu.Unlock() + + instance, exists := w.libraryWatchers[libraryID] + if !exists { + log.Debug(ctx, "No watcher found to stop", "libraryID", libraryID) + return nil + } + + instance.cancel() + delete(w.libraryWatchers, libraryID) + + log.Info(ctx, "Stopped watcher for library", "libraryID", libraryID, "name", instance.library.Name) + return nil +} + +// watchLibrary implements the core watching logic for a single library (extracted from old watchLib function) +func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { s, err := storage.For(lib.Path) if err != nil { - log.Error(ctx, "Watcher: Error creating storage", "library", lib.ID, "path", lib.Path, err) - return + return fmt.Errorf("creating storage: %w", err) } + fsys, err := s.FS() if err != nil { - log.Error(ctx, "Watcher: Error getting FS", "library", lib.ID, "path", lib.Path, err) - return + return fmt.Errorf("getting FS: %w", err) } + watcher, ok := s.(storage.Watcher) if !ok { - log.Info(ctx, "Watcher not supported", "library", lib.ID, "path", lib.Path) - return + log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path) + return nil } + c, err := watcher.Start(ctx) if err != nil { - log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err) - return + return fmt.Errorf("starting watcher: %w", err) } + absLibPath, err := filepath.Abs(lib.Path) if err != nil { - log.Error(ctx, "Watcher: Error converting lib.Path to absolute", "library", lib.ID, "path", lib.Path, err) - return + return fmt.Errorf("converting to absolute path: %w", err) } - log.Info(ctx, "Watcher started", "library", lib.ID, "libPath", lib.Path, "absoluteLibPath", absLibPath) + + log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath) + for { select { case <-ctx.Done(): - return + log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name) + return nil case path := <-c: path, err = filepath.Rel(absLibPath, path) if err != nil { - log.Error(ctx, "Watcher: Error getting relative path", "library", lib.ID, "libPath", absLibPath, "path", path, err) + log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err) continue } + if isIgnoredPath(ctx, fsys, path) { - log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path) + log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path) continue } - log.Trace(ctx, "Watcher: Detected change", "library", lib.ID, "path", path, "libPath", absLibPath) - watchChan <- struct{}{} + + log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath) + + // Notify the main watcher of changes + select { + case w.watcherNotify <- *lib: + default: + // Channel is full, notification already pending + } } } } diff --git a/server/events/events.go b/server/events/events.go index e8dcd81f0..ff0a8a40a 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -13,8 +13,8 @@ type eventCtxKey string const broadcastToAllKey eventCtxKey = "broadcastToAll" -// BroadcastToAll is a context key that can be used to broadcast an event to all clients -func BroadcastToAll(ctx context.Context) context.Context { +// broadcastToAll is a context key that can be used to broadcast an event to all clients +func broadcastToAll(ctx context.Context) context.Context { return context.WithValue(ctx, broadcastToAllKey, true) } diff --git a/server/events/sse.go b/server/events/sse.go index 690c79937..54a602985 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -19,6 +19,7 @@ import ( type Broker interface { http.Handler SendMessage(ctx context.Context, event Event) + SendBroadcastMessage(ctx context.Context, event Event) } const ( @@ -77,6 +78,11 @@ func GetBroker() Broker { }) } +func (b *broker) SendBroadcastMessage(ctx context.Context, evt Event) { + ctx = broadcastToAll(ctx) + b.SendMessage(ctx, evt) +} + func (b *broker) SendMessage(ctx context.Context, evt Event) { msg := b.prepareMessage(ctx, evt) log.Trace("Broker received new event", "type", msg.event, "data", msg.data) @@ -280,4 +286,6 @@ type noopBroker struct { http.Handler } +func (b noopBroker) SendBroadcastMessage(context.Context, Event) {} + func (noopBroker) SendMessage(context.Context, Event) {} diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index d708d72f9..9a86a9add 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" ) // sensitiveFieldsPartialMask contains configuration field names that should be redacted @@ -99,11 +98,6 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]interface func getConfig(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !user.IsAdmin { - http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized) - return - } // Marshal the actual configuration struct to preserve original field names configBytes, err := json.Marshal(*conf.Server) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 52baef83a..60f7c3394 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -1,109 +1,171 @@ package nativeapi import ( + "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("getConfig", func() { +var _ = Describe("Config API", func() { + var ds model.DataStore + var router http.Handler + var adminUser, regularUser model.User + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.DevUIShowConfig = true // Enable config endpoint for tests + ds = &tests.MockDataStore{} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + router = server.JWTVerifier(nativeRouter) + + // Create test users + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "adminpass", + } + regularUser = model.User{ + ID: "user-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + NewPassword: "userpass", + } + + // Store in mock datastore + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) }) - Context("when user is not admin", func() { - It("returns unauthorized", func() { - req := httptest.NewRequest("GET", "/config", nil) - w := httptest.NewRecorder() - ctx := request.WithUser(req.Context(), model.User{IsAdmin: false}) + Describe("GET /api/config", func() { + Context("as admin user", func() { + var adminToken string - getConfig(w, req.WithContext(ctx)) + BeforeEach(func() { + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + }) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) + It("returns config successfully", func() { + req := createAuthenticatedConfigRequest(adminToken) + w := httptest.NewRecorder() - Context("when user is admin", func() { - It("returns config successfully", func() { - req := httptest.NewRequest("GET", "/config", nil) - w := httptest.NewRecorder() - ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + router.ServeHTTP(w, req) - getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.ID).To(Equal("config")) + Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile)) + Expect(resp.Config).ToNot(BeEmpty()) + }) - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - Expect(resp.ID).To(Equal("config")) - Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile)) - Expect(resp.Config).ToNot(BeEmpty()) + It("redacts sensitive fields", func() { + conf.Server.LastFM.ApiKey = "secretapikey123" + conf.Server.Spotify.Secret = "spotifysecret456" + conf.Server.PasswordEncryptionKey = "encryptionkey789" + conf.Server.DevAutoCreateAdminPassword = "adminpassword123" + conf.Server.Prometheus.Password = "prometheuspass" + + req := createAuthenticatedConfigRequest(adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + // Check LastFM.ApiKey (partially masked) + lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(lastfm["ApiKey"]).To(Equal("s*************3")) + + // Check Spotify.Secret (partially masked) + spotify, ok := resp.Config["Spotify"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(spotify["Secret"]).To(Equal("s**************6")) + + // Check PasswordEncryptionKey (fully masked) + Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****")) + + // Check DevAutoCreateAdminPassword (fully masked) + Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****")) + + // Check Prometheus.Password (fully masked) + prometheus, ok := resp.Config["Prometheus"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(prometheus["Password"]).To(Equal("****")) + }) + + It("handles empty sensitive values", func() { + conf.Server.LastFM.ApiKey = "" + conf.Server.PasswordEncryptionKey = "" + + req := createAuthenticatedConfigRequest(adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + // Check LastFM.ApiKey - should be preserved because it's sensitive + lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(lastfm["ApiKey"]).To(Equal("")) + + // Empty sensitive values should remain empty - should be preserved because it's sensitive + Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("")) + }) }) - It("redacts sensitive fields", func() { - conf.Server.LastFM.ApiKey = "secretapikey123" - conf.Server.Spotify.Secret = "spotifysecret456" - conf.Server.PasswordEncryptionKey = "encryptionkey789" - conf.Server.DevAutoCreateAdminPassword = "adminpassword123" - conf.Server.Prometheus.Password = "prometheuspass" + Context("as regular user", func() { + var userToken string - req := httptest.NewRequest("GET", "/config", nil) - w := httptest.NewRecorder() - ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) - getConfig(w, req.WithContext(ctx)) + BeforeEach(func() { + var err error + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + It("denies access with forbidden status", func() { + req := createAuthenticatedConfigRequest(userToken) + w := httptest.NewRecorder() - // Check LastFM.ApiKey (partially masked) - lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(lastfm["ApiKey"]).To(Equal("s*************3")) + router.ServeHTTP(w, req) - // Check Spotify.Secret (partially masked) - spotify, ok := resp.Config["Spotify"].(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(spotify["Secret"]).To(Equal("s**************6")) - - // Check PasswordEncryptionKey (fully masked) - Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****")) - - // Check DevAutoCreateAdminPassword (fully masked) - Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****")) - - // Check Prometheus.Password (fully masked) - prometheus, ok := resp.Config["Prometheus"].(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(prometheus["Password"]).To(Equal("****")) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) }) - It("handles empty sensitive values", func() { - conf.Server.LastFM.ApiKey = "" - conf.Server.PasswordEncryptionKey = "" + Context("without authentication", func() { + It("denies access with unauthorized status", func() { + req := createUnauthenticatedConfigRequest("GET", "/config/", nil) + w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/config", nil) - w := httptest.NewRecorder() - ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) - getConfig(w, req.WithContext(ctx)) + router.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - - // Check LastFM.ApiKey - should be preserved because it's sensitive - lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(lastfm["ApiKey"]).To(Equal("")) - - // Empty sensitive values should remain empty - should be preserved because it's sensitive - Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("")) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) }) }) }) @@ -145,3 +207,21 @@ var _ = Describe("redactValue function", func() { Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g")) }) }) + +// Helper functions + +func createAuthenticatedConfigRequest(token string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/config/config", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + return req +} + +func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request { + if body == nil { + body = &bytes.Buffer{} + } + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + return req +} diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index e74dc99c0..3178395ce 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -9,7 +9,6 @@ import ( "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" ) @@ -30,11 +29,6 @@ func inspect(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !user.IsAdmin { - http.Error(w, "Inspect is only available to admin users", http.StatusUnauthorized) - } - p := req.Params(r) id, err := p.String("id") diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go new file mode 100644 index 000000000..f081eca78 --- /dev/null +++ b/server/nativeapi/library.go @@ -0,0 +1,101 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// User-library association endpoints (admin only) +func (n *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)) + }) +} + +// Middleware to parse user ID from URL +func parseUserIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + ctx := context.WithValue(r.Context(), "userID", userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// User-library association handlers + +func getUserLibraries(service core.Library) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value("userID").(string) + + libraries, err := service.GetUserLibraries(r.Context(), userID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "User not found", http.StatusNotFound) + return + } + log.Error(r.Context(), "Error getting user libraries", "userID", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(libraries); err != nil { + log.Error(r.Context(), "Error encoding user libraries response", err) + } + } +} + +func setUserLibraries(service core.Library) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value("userID").(string) + + var request struct { + LibraryIDs []int `json:"libraryIds"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + log.Error(r.Context(), "Error decoding request", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil { + log.Error(r.Context(), "Error setting user libraries", "userID", userID, err) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "User not found", http.StatusNotFound) + return + } + if errors.Is(err, model.ErrValidation) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, "Failed to set user libraries", http.StatusInternalServerError) + return + } + + // Return updated user libraries + libraries, err := service.GetUserLibraries(r.Context(), userID) + if err != nil { + log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(libraries); err != nil { + log.Error(r.Context(), "Error encoding user libraries response", err) + } + } +} diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go new file mode 100644 index 000000000..4e6d34582 --- /dev/null +++ b/server/nativeapi/library_test.go @@ -0,0 +1,424 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Library API", func() { + var ds model.DataStore + var router http.Handler + var adminUser, regularUser model.User + var library1, library2 model.Library + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds = &tests.MockDataStore{} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + router = server.JWTVerifier(nativeRouter) + + // Create test users + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "adminpass", + } + regularUser = model.User{ + ID: "user-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + NewPassword: "userpass", + } + + // Create test libraries + library1 = model.Library{ + ID: 1, + Name: "Test Library 1", + Path: "/music/library1", + } + library2 = model.Library{ + ID: 2, + Name: "Test Library 2", + Path: "/music/library2", + } + + // Store in mock datastore + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed()) + }) + + Describe("Library CRUD Operations", func() { + Context("as admin user", func() { + var adminToken string + + BeforeEach(func() { + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("GET /api/library", func() { + It("returns all libraries", func() { + req := createAuthenticatedRequest("GET", "/library", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var libraries []model.Library + err := json.Unmarshal(w.Body.Bytes(), &libraries) + Expect(err).ToNot(HaveOccurred()) + Expect(libraries).To(HaveLen(2)) + Expect(libraries[0].Name).To(Equal("Test Library 1")) + Expect(libraries[1].Name).To(Equal("Test Library 2")) + }) + }) + + Describe("GET /api/library/{id}", func() { + It("returns a specific library", func() { + req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var library model.Library + err := json.Unmarshal(w.Body.Bytes(), &library) + Expect(err).ToNot(HaveOccurred()) + Expect(library.Name).To(Equal("Test Library 1")) + Expect(library.Path).To(Equal("/music/library1")) + }) + + It("returns 404 for non-existent library", func() { + req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for invalid library ID", func() { + req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("POST /api/library", func() { + It("creates a new library", func() { + newLibrary := model.Library{ + Name: "New Library", + Path: "/music/new", + } + body, _ := json.Marshal(newLibrary) + req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("validates required fields", func() { + invalidLibrary := model.Library{ + Name: "", // Missing name + Path: "/music/invalid", + } + body, _ := json.Marshal(invalidLibrary) + req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("library name is required")) + }) + + It("validates path field", func() { + invalidLibrary := model.Library{ + Name: "Valid Name", + Path: "", // Missing path + } + body, _ := json.Marshal(invalidLibrary) + req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("library path is required")) + }) + }) + + Describe("PUT /api/library/{id}", func() { + It("updates an existing library", func() { + updatedLibrary := model.Library{ + Name: "Updated Library 1", + Path: "/music/updated", + } + body, _ := json.Marshal(updatedLibrary) + req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var updated model.Library + err := json.Unmarshal(w.Body.Bytes(), &updated) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.ID).To(Equal(1)) + Expect(updated.Name).To(Equal("Updated Library 1")) + Expect(updated.Path).To(Equal("/music/updated")) + }) + + It("validates required fields on update", func() { + invalidLibrary := model.Library{ + Name: "", + Path: "/music/path", + } + body, _ := json.Marshal(invalidLibrary) + req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("DELETE /api/library/{id}", func() { + It("deletes an existing library", func() { + req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 for non-existent library", func() { + req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("as regular user", func() { + var userToken string + + BeforeEach(func() { + var err error + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies access to library management endpoints", func() { + endpoints := []string{ + "GET /library", + "POST /library", + "GET /library/1", + "PUT /library/1", + "DELETE /library/1", + } + + for _, endpoint := range endpoints { + parts := strings.Split(endpoint, " ") + method, path := parts[0], parts[1] + + req := createAuthenticatedRequest(method, path, nil, userToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + } + }) + }) + + Context("without authentication", func() { + It("denies access to library management endpoints", func() { + req := createUnauthenticatedRequest("GET", "/library", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + }) + + Describe("User-Library Association Operations", func() { + Context("as admin user", func() { + var adminToken string + + BeforeEach(func() { + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("GET /api/user/{id}/library", func() { + It("returns user's libraries", func() { + // Set up user libraries + err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2}) + Expect(err).ToNot(HaveOccurred()) + + req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var libraries []model.Library + err = json.Unmarshal(w.Body.Bytes(), &libraries) + Expect(err).ToNot(HaveOccurred()) + Expect(libraries).To(HaveLen(2)) + }) + + It("returns 404 for non-existent user", func() { + req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("PUT /api/user/{id}/library", func() { + It("sets user's libraries", func() { + request := map[string][]int{ + "libraryIds": {1, 2}, + } + body, _ := json.Marshal(request) + req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var libraries []model.Library + err := json.Unmarshal(w.Body.Bytes(), &libraries) + Expect(err).ToNot(HaveOccurred()) + Expect(libraries).To(HaveLen(2)) + }) + + It("validates library IDs exist", func() { + request := map[string][]int{ + "libraryIds": {999}, // Non-existent library + } + body, _ := json.Marshal(request) + req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist")) + }) + + It("requires at least one library for regular users", func() { + request := map[string][]int{ + "libraryIds": {}, // Empty libraries + } + body, _ := json.Marshal(request) + req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned")) + }) + + It("prevents manual assignment to admin users", func() { + request := map[string][]int{ + "libraryIds": {1}, + } + body, _ := json.Marshal(request) + req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users")) + }) + }) + }) + + Context("as regular user", func() { + var userToken string + + BeforeEach(func() { + var err error + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + It("denies access to user-library association endpoints", func() { + req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + }) + }) +}) + +// Helper functions + +func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request { + if body == nil { + body = &bytes.Buffer{} + } + req := httptest.NewRequest(method, path, body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + return req +} + +func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request { + if body == nil { + body = &bytes.Buffer{} + } + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + return req +} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index aed24e963..370bdbd1e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" ) @@ -25,10 +26,11 @@ type Router struct { share core.Share playlists core.Playlists insights metrics.Insights + libs core.Library } -func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights} +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} r.Handler = r.routes() return r } @@ -62,10 +64,15 @@ func (n *Router) routes() http.Handler { n.addSongPlaylistsRoute(r) n.addQueueRoute(r) n.addMissingFilesRoute(r) - n.addInspectRoute(r) - n.addConfigRoute(r) n.addKeepAliveRoute(r) n.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) + }) }) return r @@ -227,3 +234,15 @@ func (n *Router) addInsightsRoute(r chi.Router) { } }) } + +// Middleware to ensure only admin users can access endpoints +func adminOnlyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := request.UserFrom(r.Context()) + if !ok || !user.IsAdmin { + http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index 0b183c1d9..d7209a164 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -2,20 +2,17 @@ package nativeapi import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -23,31 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// Simple mock implementations for missing types -type mockShare struct { - core.Share -} - -func (m *mockShare) NewRepository(ctx context.Context) rest.Repository { - return &tests.MockShareRepo{} -} - -type mockPlaylists struct { - core.Playlists -} - -func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { - return &model.Playlist{}, nil -} - -type mockInsights struct { - metrics.Insights -} - -func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) { - return time.Now(), true -} - var _ = Describe("Song Endpoints", func() { var ( router http.Handler @@ -122,13 +94,8 @@ var _ = Describe("Song Endpoints", func() { } mfRepo.SetData(testSongs) - // Setup router with mocked dependencies - mockShareImpl := &mockShare{} - mockPlaylistsImpl := &mockPlaylists{} - mockInsightsImpl := &mockInsights{} - // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 39a164500..56cf469c5 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/run" "github.com/navidrome/navidrome/utils/slice" ) @@ -61,6 +62,13 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ) } + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, 0, err + } + opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + opts.Offset = p.IntOr("offset", 0) opts.Max = min(p.IntOr("size", 10), 500) albums, err := api.ds.Album(r.Context()).GetAll(opts) @@ -109,57 +117,87 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo return response, nil } -func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) { +func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) { ctx := r.Context() - artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred()) + + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) if err != nil { - log.Error(r, "Error retrieving starred artists", err) - return nil, err + return nil, nil, nil, err } - options := filter.ByStarred() - albums, err := api.ds.Album(ctx).GetAll(options) + + // Prepare variables to capture results from parallel execution + var artists model.Artists + var albums model.Albums + var mediaFiles model.MediaFiles + + // Execute all three queries in parallel for better performance + err = run.Parallel( + // Query starred artists + func() error { + artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds) + var err error + artists, err = api.ds.Artist(ctx).GetAll(artistOpts) + if err != nil { + log.Error(r, "Error retrieving starred artists", err) + } + return err + }, + // Query starred albums + func() error { + albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) + var err error + albums, err = api.ds.Album(ctx).GetAll(albumOpts) + if err != nil { + log.Error(r, "Error retrieving starred albums", err) + } + return err + }, + // Query starred media files + func() error { + mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) + var err error + mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts) + if err != nil { + log.Error(r, "Error retrieving starred mediaFiles", err) + } + return err + }, + )() + + // Return the first error if any occurred if err != nil { - log.Error(r, "Error retrieving starred albums", err) - return nil, err + return nil, nil, nil, err } - mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options) + + return artists, albums, mediaFiles, nil +} + +func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) { + artists, albums, mediaFiles, err := api.getStarredItems(r) if err != nil { - log.Error(r, "Error retrieving starred mediaFiles", err) return nil, err } response := newResponse() response.Starred = &responses.Starred{} response.Starred.Artist = slice.MapWithArg(artists, r, toArtist) - response.Starred.Album = slice.MapWithArg(albums, ctx, childFromAlbum) - response.Starred.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile) + response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum) + response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile) return response, nil } func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) { - ctx := r.Context() - artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred()) + artists, albums, mediaFiles, err := api.getStarredItems(r) if err != nil { - log.Error(r, "Error retrieving starred artists", err) - return nil, err - } - options := filter.ByStarred() - albums, err := api.ds.Album(ctx).GetAll(options) - if err != nil { - log.Error(r, "Error retrieving starred albums", err) - return nil, err - } - mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options) - if err != nil { - log.Error(r, "Error retrieving starred mediaFiles", err) return nil, err } response := newResponse() response.Starred2 = &responses.Starred2{} response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3) - response.Starred2.Album = slice.MapWithArg(albums, ctx, buildAlbumID3) - response.Starred2.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile) + response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3) + response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile) return response, nil } @@ -193,7 +231,15 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) fromYear := p.IntOr("fromYear", 0) toYear := p.IntOr("toYear", 0) - songs, err := api.getSongs(r.Context(), 0, size, filter.SongsByRandom(genre, fromYear, toYear)) + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + opts := filter.SongsByRandom(genre, fromYear, toYear) + opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + + songs, err := api.getSongs(r.Context(), 0, size, opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err @@ -211,8 +257,16 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) genre, _ := p.String("genre") + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + opts := filter.ByGenre(genre) + opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + ctx := r.Context() - songs, err := api.getSongs(ctx, offset, count, filter.ByGenre(genre)) + songs, err := api.getSongs(ctx, offset, count, opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ffd1803c6..63c2614cd 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -5,12 +5,13 @@ import ( "errors" "net/http/httptest" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/req" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -24,6 +25,7 @@ var _ = Describe("Album Lists", func() { BeforeEach(func() { ds = &tests.MockDataStore{} + auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() @@ -63,6 +65,74 @@ var _ = Describe("Album Lists", func() { errors.As(err, &subErr) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter albums by specific library when musicFolderId is provided", func() { + r := newGetRequest("type=newest", "musicFolderId=1") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?)")) + Expect(args).To(ContainElement(1)) + }) + + It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() { + r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?)")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("should return all accessible albums when no musicFolderId is provided", func() { + r := newGetRequest("type=newest") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) + Expect(args).To(ContainElements(1, 2, 3)) + }) + }) }) Describe("GetAlbumList2", func() { @@ -100,5 +170,373 @@ var _ = Describe("Album Lists", func() { errors.As(err, &subErr) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter albums by specific library when musicFolderId is provided", func() { + r := newGetRequest("type=newest", "musicFolderId=1") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList2.Album).To(HaveLen(2)) + // Verify that library filter was applied + Expect(mockRepo.Options.Filters).ToNot(BeNil()) + }) + + It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() { + r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList2.Album).To(HaveLen(2)) + // Verify that library filter was applied + Expect(mockRepo.Options.Filters).ToNot(BeNil()) + }) + + It("should return all accessible albums when no musicFolderId is provided", func() { + r := newGetRequest("type=newest") + r = r.WithContext(ctx) + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList2.Album).To(HaveLen(2)) + }) + }) + }) + + Describe("GetRandomSongs", func() { + var mockMediaFileRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("should return random songs", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("size=2") + + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter songs by specific library when musicFolderId is provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("size=2", "musicFolderId=1") + r = r.WithContext(ctx) + + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?)")) + Expect(args).To(ContainElement(1)) + }) + + It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2") + r = r.WithContext(ctx) + + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?)")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("should return all accessible songs when no musicFolderId is provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("size=2") + r = r.WithContext(ctx) + + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) + Expect(args).To(ContainElements(1, 2, 3)) + }) + }) + }) + + Describe("GetSongsByGenre", func() { + var mockMediaFileRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("should return songs by genre", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("count=2", "genre=rock") + + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) + }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter songs by specific library when musicFolderId is provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("count=2", "genre=rock", "musicFolderId=1") + r = r.WithContext(ctx) + + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?)")) + Expect(args).To(ContainElement(1)) + }) + + It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2") + r = r.WithContext(ctx) + + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?)")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("should return all accessible songs when no musicFolderId is provided", func() { + mockMediaFileRepo.SetData(model.MediaFiles{ + {ID: "1", Title: "Song 1"}, + {ID: "2", Title: "Song 2"}, + }) + r := newGetRequest("count=2", "genre=rock") + r = r.WithContext(ctx) + + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) + // Verify that library filter was applied + query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() + Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) + Expect(args).To(ContainElements(1, 2, 3)) + }) + }) + }) + + Describe("GetStarred", func() { + var mockArtistRepo *tests.MockArtistRepo + var mockAlbumRepo *tests.MockAlbumRepo + var mockMediaFileRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("should return starred items", func() { + mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) + mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) + mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) + r := newGetRequest() + + resp, err := router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred.Artist).To(HaveLen(1)) + Expect(resp.Starred.Album).To(HaveLen(1)) + Expect(resp.Starred.Song).To(HaveLen(1)) + }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter starred items by specific library when musicFolderId is provided", func() { + mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) + mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) + mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) + r := newGetRequest("musicFolderId=1") + r = r.WithContext(ctx) + + resp, err := router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred.Artist).To(HaveLen(1)) + Expect(resp.Starred.Album).To(HaveLen(1)) + Expect(resp.Starred.Song).To(HaveLen(1)) + // Verify that library filter was applied to all types + artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql() + Expect(artistQuery).To(ContainSubstring("library_id IN (?)")) + Expect(artistArgs).To(ContainElement(1)) + }) + }) + }) + + Describe("GetStarred2", func() { + var mockArtistRepo *tests.MockArtistRepo + var mockAlbumRepo *tests.MockAlbumRepo + var mockMediaFileRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("should return starred items in ID3 format", func() { + mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) + mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) + mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) + r := newGetRequest() + + resp, err := router.GetStarred2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred2.Artist).To(HaveLen(1)) + Expect(resp.Starred2.Album).To(HaveLen(1)) + Expect(resp.Starred2.Song).To(HaveLen(1)) + }) + + Context("with musicFolderId parameter", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + It("should filter starred items by specific library when musicFolderId is provided", func() { + mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) + mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) + mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) + r := newGetRequest("musicFolderId=1") + r = r.WithContext(ctx) + + resp, err := router.GetStarred2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred2.Artist).To(HaveLen(1)) + Expect(resp.Starred2.Album).To(HaveLen(1)) + Expect(resp.Starred2.Song).To(HaveLen(1)) + // Verify that library filter was applied to all types + artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql() + Expect(artistQuery).To(ContainSubstring("library_id IN (?)")) + Expect(artistArgs).To(ContainElement(1)) + }) + }) }) }) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index db4e6ded1..c8584543d 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -7,6 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" @@ -17,7 +18,8 @@ import ( ) func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) { - libraries, _ := api.ds.Library(r.Context()).GetAll() + libraries := getUserAccessibleLibraries(r.Context()) + folders := make([]responses.MusicFolder, len(libraries)) for i, f := range libraries { folders[i].Id = int32(f.ID) @@ -28,28 +30,37 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) return response, nil } -func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { +func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { ctx := r.Context() - lib, err := api.ds.Library(ctx).Get(libId) + + lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") if err != nil { - log.Error(ctx, "Error retrieving Library", "id", libId, err) + log.Error(ctx, "Error retrieving last scan start time", err) return nil, 0, err } + lastScan := time.Now() + if lastScanStr != "" { + lastScan, err = time.Parse(time.RFC3339, lastScanStr) + } var indexes model.ArtistIndexes - if lib.LastScanAt.After(ifModifiedSince) { - indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist) + if lastScan.After(ifModifiedSince) { + indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist) if err != nil { log.Error(ctx, "Error retrieving Indexes", err) return nil, 0, err } + if len(indexes) == 0 { + log.Debug(ctx, "No artists found in library", "libId", libIds) + return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty") + } } - return indexes, lib.LastScanAt.UnixMilli(), err + return indexes, lastScan.UnixMilli(), err } -func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Indexes, error) { - indexes, modified, err := api.getArtist(r, libId, ifModifiedSince) +func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) { + indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince) if err != nil { return nil, err } @@ -67,8 +78,8 @@ func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince ti return res, nil } -func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Artists, error) { - indexes, modified, err := api.getArtist(r, libId, ifModifiedSince) +func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) { + indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince) if err != nil { return nil, err } @@ -88,10 +99,10 @@ func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - musicFolderId := p.IntOr("musicFolderId", 1) + musicFolderIds, _ := selectedMusicFolderIds(r, false) ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) - res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince) + res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince) if err != nil { return nil, err } @@ -102,9 +113,9 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - musicFolderId := p.IntOr("musicFolderId", 1) - res, err := api.getArtistIndexID3(r, musicFolderId, time.Time{}) + musicFolderIds, _ := selectedMusicFolderIds(r, false) + + res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{}) if err != nil { return nil, err } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go new file mode 100644 index 000000000..b8f510aed --- /dev/null +++ b/server/subsonic/browsing_test.go @@ -0,0 +1,160 @@ +package subsonic + +import ( + "context" + "fmt" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context { + libraries := make([]model.Library, len(libraryIDs)) + for i, id := range libraryIDs { + libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)} + } + user := model.User{ + ID: userID, + Libraries: libraries, + } + return request.WithUser(ctx, user) +} + +var _ = Describe("Browsing", func() { + var api *Router + var ctx context.Context + var ds model.DataStore + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + api = &Router{ds: ds} + ctx = context.Background() + }) + + Describe("GetMusicFolders", func() { + It("should return all libraries the user has access", func() { + // Create mock user with libraries + ctx := contextWithUser(ctx, "user-id", 1, 2, 3) + + // Create request + r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetMusicFolders(r) + + // Verify results + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.MusicFolders).ToNot(BeNil()) + Expect(response.MusicFolders.Folders).To(HaveLen(3)) + Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1")) + Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2")) + Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3")) + }) + }) + + Describe("GetIndexes", func() { + It("should validate user access to the specified musicFolderId", func() { + // Create mock user with access to library 1 only + ctx = contextWithUser(ctx, "user-id", 1) + + // Create request with musicFolderId=2 (not accessible) + r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetIndexes(r) + + // Should return error due to lack of access + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + + It("should default to first accessible library when no musicFolderId specified", func() { + // Create mock user with access to libraries 2 and 3 + ctx = contextWithUser(ctx, "user-id", 2, 3) + + // Setup minimal mock library data for working tests + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(model.Libraries{ + {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, + {ID: 3, Name: "Test Library 3", Path: "/music/library3"}, + }) + + // Setup mock artist data + mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) + mockArtistRepo.SetData(model.Artists{ + {ID: "1", Name: "Test Artist 1"}, + {ID: "2", Name: "Test Artist 2"}, + }) + + // Create request without musicFolderId + r := httptest.NewRequest("GET", "/rest/getIndexes", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetIndexes(r) + + // Should succeed and use first accessible library (2) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.Indexes).ToNot(BeNil()) + }) + }) + + Describe("GetArtists", func() { + It("should validate user access to the specified musicFolderId", func() { + // Create mock user with access to library 1 only + ctx = contextWithUser(ctx, "user-id", 1) + + // Create request with musicFolderId=3 (not accessible) + r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetArtists(r) + + // Should return error due to lack of access + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + + It("should default to first accessible library when no musicFolderId specified", func() { + // Create mock user with access to libraries 1 and 2 + ctx = contextWithUser(ctx, "user-id", 1, 2) + + // Setup minimal mock library data for working tests + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(model.Libraries{ + {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, + {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, + }) + + // Setup mock artist data + mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) + mockArtistRepo.SetData(model.Artists{ + {ID: "1", Name: "Test Artist 1"}, + {ID: "2", Name: "Test Artist 2"}, + }) + + // Create request without musicFolderId + r := httptest.NewRequest("GET", "/rest/getArtists", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.GetArtists(r) + + // Should succeed and use first accessible library (1) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + Expect(response.Artist).ToNot(BeNil()) + }) + }) +}) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 656973a4b..a0bce9041 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -123,6 +123,38 @@ func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { }) } +func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { + if len(musicFolderIds) == 0 { + return opts + } + + libraryFilter := Eq{"library_id": musicFolderIds} + if opts.Filters == nil { + opts.Filters = libraryFilter + } else { + opts.Filters = And{opts.Filters, libraryFilter} + } + + return opts +} + +// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists +// that are associated with the specified music folders are included in the results. +func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { + if len(musicFolderIds) == 0 { + return opts + } + + artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds} + if opts.Filters == nil { + opts.Filters = artistLibraryFilter + } else { + opts.Filters = And{opts.Filters, artistLibraryFilter} + } + + return opts +} + func ByGenre(genre string) Options { return addDefaultFilters(Options{ Sort: "name", diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 58834587d..f9733bb3f 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -7,6 +7,7 @@ import ( "fmt" "mime" "net/http" + "slices" "sort" "strings" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/number" + "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -474,3 +476,40 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses } return res } + +// getUserAccessibleLibraries returns the list of libraries the current user has access to. +func getUserAccessibleLibraries(ctx context.Context) []model.Library { + user := getUser(ctx) + return user.Libraries +} + +// selectedMusicFolderIds retrieves the music folder IDs from the request parameters. +// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context). +// If the parameter is required and not present, it returns an error. +// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound. +func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) { + p := req.Params(r) + musicFolderIds, err := p.Ints("musicFolderId") + + // If the parameter is not present, it returns an error if it is required. + if errors.Is(err, req.ErrMissingParam) && required { + return nil, err + } + + // Get user's accessible libraries for validation + libraries := getUserAccessibleLibraries(r.Context()) + accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID }) + + if len(musicFolderIds) > 0 { + // Validate all provided library IDs - if any are invalid, return an error + for _, id := range musicFolderIds { + if !slices.Contains(accessibleLibraryIds, id) { + return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id) + } + } + return musicFolderIds, nil + } + + // If no musicFolderId is provided, return all libraries the user has access to. + return accessibleLibraryIds, nil +} diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index a4978237b..a6508d4bb 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -1,10 +1,15 @@ package subsonic import ( + "context" + "net/http/httptest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -163,4 +168,108 @@ var _ = Describe("helpers", func() { Expect(result).To(Equal(int32(4))) }) }) + + Describe("selectedMusicFolderIds", func() { + var user model.User + var ctx context.Context + + BeforeEach(func() { + user = model.User{ + ID: "test-user", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + } + ctx = request.WithUser(context.Background(), user) + }) + + Context("when musicFolderId parameter is provided", func() { + It("should return the specified musicFolderId values", func() { + r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{1, 3})) + }) + + It("should ignore invalid musicFolderId parameter values", func() { + r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{2})) // Only valid ID is returned + }) + + It("should return error when any library ID is not accessible", func() { + r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible")) + Expect(ids).To(BeNil()) + }) + }) + + Context("when musicFolderId parameter is not provided", func() { + Context("and required is false", func() { + It("should return all user's library IDs", func() { + r := httptest.NewRequest("GET", "/test", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{1, 2, 3})) + }) + + It("should return empty slice when user has no libraries", func() { + userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}} + ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs) + r := httptest.NewRequest("GET", "/test", nil) + r = r.WithContext(ctxWithoutLibs) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{})) + }) + }) + + Context("and required is true", func() { + It("should return ErrMissingParam error", func() { + r := httptest.NewRequest("GET", "/test", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, true) + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(ids).To(BeNil()) + }) + }) + }) + + Context("when musicFolderId parameter is empty", func() { + It("should return all user's library IDs even when empty parameter is provided", func() { + r := httptest.NewRequest("GET", "/test?musicFolderId=", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{1, 2, 3})) + }) + }) + + Context("when all musicFolderId parameters are invalid", func() { + It("should return all user libraries when all musicFolderId parameters are invalid", func() { + r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil) + r = r.WithContext(ctx) + + ids, err := selectedMusicFolderIds(r, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries + }) + }) + }) }) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index c7a8937fc..6f09f5349 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.Events = append(f.Events, event) } +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 83b0408ff..23fac6814 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -76,7 +76,7 @@ func (api *Router) create(ctx context.Context, playlistId, name string, ids []st pls.OwnerID = owner.ID } pls.Tracks = nil - pls.AddTracks(ids) + pls.AddMediaFilesByID(ids) err = tx.Playlist(ctx).Put(pls) playlistId = pls.ID diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index f66846f35..d8f85afeb 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -8,6 +8,7 @@ import ( "strings" "time" + . "github.com/Masterminds/squirrel" "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -41,9 +42,9 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { return sp, nil } -type searchFunc[T any] func(q string, offset int, size int, includeMissing bool) (T, error) +type searchFunc[T any] func(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (T, error) -func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T) func() error { +func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error { return func() error { if size == 0 { return nil @@ -51,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(q, offset, size, false) + *result, err = s(q, offset, size, false, options...) if err != nil { log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) } else { @@ -61,15 +62,23 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s } } -func (api *Router) searchAll(ctx context.Context, sp *searchParams) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { +func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderIds []int) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { start := time.Now() q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) + // Create query options for library filtering + var options []model.QueryOptions + if len(musicFolderIds) > 0 { + options = append(options, model.QueryOptions{ + Filters: Eq{"library_id": musicFolderIds}, + }) + } + // Run searches in parallel g, ctx := errgroup.WithContext(ctx) - g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles)) - g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums)) - g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists)) + g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...)) + g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...)) + g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, options...)) err := g.Wait() if err == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", @@ -86,7 +95,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { if err != nil { return nil, err } - mfs, als, as := api.searchAll(ctx, sp) + + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + mfs, als, as := api.searchAll(ctx, sp, musicFolderIds) response := newResponse() searchResult2 := &responses.SearchResult2{} @@ -115,7 +130,13 @@ func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) { if err != nil { return nil, err } - mfs, als, as := api.searchAll(ctx, sp) + + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + mfs, als, as := api.searchAll(ctx, sp, musicFolderIds) response := newResponse() searchResult3 := &responses.SearchResult3{} diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go new file mode 100644 index 000000000..dfe3a45c4 --- /dev/null +++ b/server/subsonic/searching_test.go @@ -0,0 +1,208 @@ +package subsonic + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Search", func() { + var router *Router + var ds model.DataStore + var mockAlbumRepo *tests.MockAlbumRepo + var mockArtistRepo *tests.MockArtistRepo + var mockMediaFileRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + // Get references to the mock repositories so we can inspect their Options + mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) + mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo) + mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo) + }) + + Context("musicFolderId parameter", func() { + assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...interface{}) { + GinkgoHelper() + query, args, err := filter.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(query).To(ContainSubstring(expectedQuery)) + Expect(args).To(ContainElements(expectedArgs...)) + } + + Describe("Search2", func() { + It("should accept musicFolderId parameter", func() { + r := newGetRequest("query=test", "musicFolderId=1") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult2).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + }) + + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + }) + r = r.WithContext(ctx) + + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult2).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories with all accessible libraries + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + }) + + It("should return empty results when user has no accessible libraries", func() { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{}, // No libraries + }) + r = r.WithContext(ctx) + + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(mockAlbumRepo.Options.Filters).To(BeNil()) + Expect(mockArtistRepo.Options.Filters).To(BeNil()) + Expect(mockMediaFileRepo.Options.Filters).To(BeNil()) + }) + + It("should return error for inaccessible musicFolderId", func() { + r := newGetRequest("query=test", "musicFolderId=999") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search2(r) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible")) + Expect(resp).To(BeNil()) + }) + }) + + Describe("Search3", func() { + It("should accept musicFolderId parameter", func() { + r := newGetRequest("query=test", "musicFolderId=1") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult3).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + }) + + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + {ID: 3, Name: "Library 3"}, + }, + }) + r = r.WithContext(ctx) + + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult3).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories with all accessible libraries + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + }) + + It("should return empty results when user has no accessible libraries", func() { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{}, // No libraries + }) + r = r.WithContext(ctx) + + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(mockAlbumRepo.Options.Filters).To(BeNil()) + Expect(mockArtistRepo.Options.Filters).To(BeNil()) + Expect(mockMediaFileRepo.Options.Filters).To(BeNil()) + }) + + It("should return error for inaccessible musicFolderId", func() { + // Test that the endpoint returns an error when user tries to access a library they don't have access to + r := newGetRequest("query=test", "musicFolderId=999") + ctx := request.WithUser(r.Context(), model.User{ + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search3(r) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible")) + Expect(resp).To(BeNil()) + }) + }) + }) +}) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 58c33c97f..27eba2fbb 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -16,10 +16,11 @@ func CreateMockAlbumRepo() *MockAlbumRepo { type MockAlbumRepo struct { model.AlbumRepository - Data map[string]*model.Album - All model.Albums - Err bool - Options model.QueryOptions + Data map[string]*model.Album + All model.Albums + Err bool + Options model.QueryOptions + ReassignAnnotationCalls map[string]string // prevID -> newID } func (m *MockAlbumRepo) SetError(err bool) { @@ -117,4 +118,44 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { return nil } +func (m *MockAlbumRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("unexpected error") + } + // Simple mock implementation - just return all albums for testing + return m.All, nil +} + +// ReassignAnnotation reassigns annotations from one album to another +func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error { + if m.Err { + return errors.New("unexpected error") + } + // Mock implementation - track the reassignment calls + if m.ReassignAnnotationCalls == nil { + m.ReassignAnnotationCalls = make(map[string]string) + } + m.ReassignAnnotationCalls[prevID] = newID + return nil +} + +// SetRating sets the rating for an album +func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + +// SetStar sets the starred status for albums +func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index da5851061..1298cbd2a 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -16,8 +16,9 @@ func CreateMockArtistRepo() *MockArtistRepo { type MockArtistRepo struct { model.ArtistRepository - Data map[string]*model.Artist - Err bool + Data map[string]*model.Artist + Err bool + Options model.QueryOptions } func (m *MockArtistRepo) SetError(err bool) { @@ -73,6 +74,9 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { } func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + if len(options) > 0 { + m.Options = options[0] + } if m.Err { return nil, errors.New("mock repo error") } @@ -108,4 +112,49 @@ func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) { return int64(len(m.Data)), nil } +func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { + if m.Err { + return nil, errors.New("mock repo error") + } + + artists, err := m.GetAll() + if err != nil { + return nil, err + } + + // For mock purposes, if no artists available, return empty result + if len(artists) == 0 { + return model.ArtistIndexes{}, nil + } + + // Simple index grouping by first letter (simplified implementation for mocks) + indexMap := make(map[string]model.Artists) + for _, artist := range artists { + key := "#" + if len(artist.Name) > 0 { + key = string(artist.Name[0]) + } + indexMap[key] = append(indexMap[key], artist) + } + + var result model.ArtistIndexes + for k, artists := range indexMap { + result = append(result, model.ArtistIndex{ID: k, Artists: artists}) + } + + return result, nil +} + +func (m *MockArtistRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("unexpected error") + } + // Simple mock implementation - just return all artists for testing + allArtists, err := m.GetAll() + return allArtists, err +} + var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 02a03e56e..56f68a74b 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -27,6 +27,7 @@ type MockDataStore struct { MockedScrobbleBuffer model.ScrobbleBufferRepository MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex + repoMu sync.Mutex } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -85,6 +86,8 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedMediaFile == nil { if db.RealDS != nil { db.MockedMediaFile = db.RealDS.MediaFile(ctx) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 7cc8b02f7..4d7539aa9 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -1,14 +1,22 @@ package tests import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "golang.org/x/exp/maps" ) type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -22,7 +30,54 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) if m.Err != nil { return nil, m.Err } - return maps.Values(m.Data), nil + var libraries model.Libraries + for _, lib := range m.Data { + libraries = append(libraries, lib) + } + // Sort by ID for predictable order + slices.SortFunc(libraries, func(a, b model.Library) int { + return a.ID - b.ID + }) + return libraries, nil +} + +func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) { + if m.Err != nil { + return 0, m.Err + } + + // If no query options, return total count + if len(qo) == 0 || qo[0].Filters == nil { + return int64(len(m.Data)), nil + } + + // Handle squirrel.Eq filter for ID validation + if eq, ok := qo[0].Filters.(squirrel.Eq); ok { + if idFilter, exists := eq["id"]; exists { + if ids, isSlice := idFilter.([]int); isSlice { + count := 0 + for _, id := range ids { + if _, exists := m.Data[id]; exists { + count++ + } + } + return int64(count), nil + } + } + } + + // Default to total count for other filters + return int64(len(m.Data)), nil +} + +func (m *MockLibraryRepo) Get(id int) (*model.Library, error) { + if m.Err != nil { + return nil, m.Err + } + if lib, ok := m.Data[id]; ok { + return &lib, nil + } + return nil, model.ErrNotFound } func (m *MockLibraryRepo) GetPath(id int) (string, error) { @@ -35,8 +90,223 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } +func (m *MockLibraryRepo) Put(library *model.Library) error { + if m.PutFn != nil { + return m.PutFn(library) + } + if m.Err != nil { + return m.Err + } + if m.Data == nil { + m.Data = make(map[int]model.Library) + } + m.Data[library.ID] = *library + return nil +} + +func (m *MockLibraryRepo) Delete(id int) error { + if m.Err != nil { + return m.Err + } + if _, ok := m.Data[id]; !ok { + return model.ErrNotFound + } + delete(m.Data, id) + return nil +} + +func (m *MockLibraryRepo) StoreMusicFolder() error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) AddArtist(id int, artistID string) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanEnd(id int) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanInProgress() (bool, error) { + if m.Err != nil { + return false, m.Err + } + return false, nil +} + func (m *MockLibraryRepo) RefreshStats(id int) error { return nil } -var _ model.LibraryRepository = &MockLibraryRepo{} +// User-library association methods - mock implementations + +func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { + if m.Err != nil { + return nil, m.Err + } + // Mock: return empty users for now + return model.Users{}, nil +} + +func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { + return m.CountAll() +} + +func (m *MockLibraryRepo) Read(id string) (interface{}, error) { + idInt, _ := strconv.Atoi(id) + mf, err := m.Get(idInt) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err +} + +func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return m.GetAll() +} + +func (m *MockLibraryRepo) EntityName() string { + return "library" +} + +func (m *MockLibraryRepo) NewInstance() interface{} { + return &model.Library{} +} + +// REST Repository methods (string-based IDs) + +func (m *MockLibraryRepo) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + if m.Err != nil { + return "", m.Err + } + + // Validate required fields + if lib.Name == "" { + return "", &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}} + } + if lib.Path == "" { + return "", &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}} + } + + // Generate ID if not set + if lib.ID == 0 { + lib.ID = len(m.Data) + 1 + } + if m.Data == nil { + m.Data = make(map[int]model.Library) + } + m.Data[lib.ID] = *lib + return strconv.Itoa(lib.ID), nil +} + +func (m *MockLibraryRepo) Update(id string, entity interface{}, cols ...string) error { + lib := entity.(*model.Library) + if m.Err != nil { + return m.Err + } + + // Validate required fields + if lib.Name == "" { + return &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}} + } + if lib.Path == "" { + return &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}} + } + + idInt, err := strconv.Atoi(id) + if err != nil { + return errors.New("invalid ID format") + } + if _, exists := m.Data[idInt]; !exists { + return rest.ErrNotFound + } + lib.ID = idInt + m.Data[idInt] = *lib + return nil +} + +func (m *MockLibraryRepo) DeleteByStringID(id string) error { + if m.Err != nil { + return m.Err + } + idInt, err := strconv.Atoi(id) + if err != nil { + return errors.New("invalid ID format") + } + if _, exists := m.Data[idInt]; !exists { + return rest.ErrNotFound + } + delete(m.Data, idInt) + return nil +} + +// Service-level methods for core.Library interface + +func (m *MockLibraryRepo) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { + if m.Err != nil { + return nil, m.Err + } + if userID == "non-existent" { + return nil, model.ErrNotFound + } + // Convert map to slice for return + var libraries model.Libraries + for _, lib := range m.Data { + libraries = append(libraries, lib) + } + // Sort by ID for predictable order + slices.SortFunc(libraries, func(a, b model.Library) int { + return a.ID - b.ID + }) + return libraries, nil +} + +func (m *MockLibraryRepo) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { + if m.Err != nil { + return m.Err + } + if userID == "non-existent" { + return model.ErrNotFound + } + if userID == "admin-1" { + return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation) + } + if len(libraryIDs) == 0 { + return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation) + } + // Validate all library IDs exist + for _, id := range libraryIDs { + if _, exists := m.Data[id]; !exists { + return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id) + } + } + return nil +} + +func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error { + if m.Err != nil { + return m.Err + } + // For testing purposes, allow access to all libraries + return nil +} + +var _ model.LibraryRepository = (*MockLibraryRepo)(nil) +var _ model.ResourceRepository = (*MockLibraryRepo)(nil) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 7bba8eda8..51c5dd10a 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -27,6 +27,10 @@ type MockMediaFileRepo struct { CountAllValue int64 CountAllOptions model.QueryOptions DeleteAllMissingValue int64 + Options model.QueryOptions + // Add fields for cross-library move detection tests + FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) + FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) } func (m *MockMediaFileRepo) SetError(err bool) { @@ -72,7 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) { + if len(qo) > 0 { + m.Options = qo[0] + } if m.Err { return nil, errors.New("error") } @@ -227,5 +234,66 @@ func (m *MockMediaFileRepo) NewInstance() interface{} { return &model.MediaFile{} } +func (m *MockMediaFileRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("unexpected error") + } + // Simple mock implementation - just return all media files for testing + allFiles, err := m.GetAll() + return allFiles, err +} + +// Cross-library move detection mock methods +func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + if m.Err { + return nil, errors.New("error") + } + if m.FindRecentFilesByMBZTrackIDFunc != nil { + return m.FindRecentFilesByMBZTrackIDFunc(missing, since) + } + // Default implementation: find files with same MBZ Track ID in other libraries + var result model.MediaFiles + for _, mf := range m.Data { + if mf.LibraryID != missing.LibraryID && + mf.MbzReleaseTrackID == missing.MbzReleaseTrackID && + mf.MbzReleaseTrackID != "" && + mf.Suffix == missing.Suffix && + mf.CreatedAt.After(since) && + !mf.Missing { + result = append(result, *mf) + } + } + return result, nil +} + +func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + if m.Err { + return nil, errors.New("error") + } + if m.FindRecentFilesByPropertiesFunc != nil { + return m.FindRecentFilesByPropertiesFunc(missing, since) + } + // Default implementation: find files with same properties in other libraries + var result model.MediaFiles + for _, mf := range m.Data { + if mf.LibraryID != missing.LibraryID && + mf.Title == missing.Title && + mf.Size == missing.Size && + mf.Suffix == missing.Suffix && + mf.DiscNumber == missing.DiscNumber && + mf.TrackNumber == missing.TrackNumber && + mf.Album == missing.Album && + mf.MbzReleaseTrackID == "" && // Exclude files with MBZ Track ID + mf.CreatedAt.After(since) && + !mf.Missing { + result = append(result, *mf) + } + } + return result, nil +} + var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 09d804ccd..9f3dd672e 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -2,6 +2,7 @@ package tests import ( "encoding/base64" + "fmt" "strings" "time" @@ -11,14 +12,16 @@ import ( func CreateMockUserRepo() *MockedUserRepo { return &MockedUserRepo{ - Data: map[string]*model.User{}, + Data: map[string]*model.User{}, + UserLibraries: map[string][]int{}, } } type MockedUserRepo struct { model.UserRepository - Error error - Data map[string]*model.User + Error error + Data map[string]*model.User + UserLibraries map[string][]int // userID -> libraryIDs } func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { @@ -55,6 +58,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use return u.FindByUsername(username) } +func (u *MockedUserRepo) Get(id string) (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + for _, usr := range u.Data { + if usr.ID == id { + return usr, nil + } + } + return nil, model.ErrNotFound +} + func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { @@ -74,3 +89,37 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { } return u.Error } + +// Library association methods - mock implementations + +func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) { + if u.Error != nil { + return nil, u.Error + } + libraryIDs, exists := u.UserLibraries[userID] + if !exists { + return model.Libraries{}, nil + } + + // Mock: Create libraries based on IDs + var libraries model.Libraries + for _, id := range libraryIDs { + libraries = append(libraries, model.Library{ + ID: id, + Name: fmt.Sprintf("Test Library %d", id), + Path: fmt.Sprintf("/music/library%d", id), + }) + } + return libraries, nil +} + +func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error { + if u.Error != nil { + return u.Error + } + if u.UserLibraries == nil { + u.UserLibraries = make(map[string][]int) + } + u.UserLibraries[userID] = libraryIDs + return nil +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 8469ac27e..dc4fe9b53 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -15,9 +15,11 @@ import artist from './artist' import playlist from './playlist' import radio from './radio' import share from './share' +import library from './library' import { Player } from './audioplayer' import customRoutes from './routes' import { + libraryReducer, themeReducer, addToPlaylistDialogReducer, expandInfoDialogReducer, @@ -56,6 +58,7 @@ const adminStore = createAdminStore({ dataProvider, history, customReducers: { + library: libraryReducer, player: playerReducer, albumView: albumViewReducer, theme: themeReducer, @@ -122,7 +125,13 @@ const Admin = (props) => { ) : ( ), - + permissions === 'admin' ? ( + + ) : null, permissions === 'admin' ? ( ({ + type: SET_SELECTED_LIBRARIES, + data: libraryIds, +}) + +export const setUserLibraries = (libraries) => ({ + type: SET_USER_LIBRARIES, + data: libraries, +}) diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx index 453dbb167..e71cd3d33 100644 --- a/ui/src/album/AlbumInfo.jsx +++ b/ui/src/album/AlbumInfo.jsx @@ -38,6 +38,7 @@ const AlbumInfo = (props) => { const record = useRecordContext(props) const data = { album: , + libraryName: , albumArtist: ( ), diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx new file mode 100644 index 000000000..1e89d3ec6 --- /dev/null +++ b/ui/src/common/LibrarySelector.jsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useDataProvider, useTranslate, useRefresh } from 'react-admin' +import { + Box, + Chip, + ClickAwayListener, + FormControl, + FormGroup, + FormControlLabel, + Checkbox, + Typography, + Paper, + Popper, + makeStyles, +} from '@material-ui/core' +import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons' +import { setSelectedLibraries, setUserLibraries } from '../actions' +import { useRefreshOnEvents } from './useRefreshOnEvents' + +const useStyles = makeStyles((theme) => ({ + root: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, + chip: { + borderRadius: theme.spacing(1), + height: theme.spacing(4.8), + fontSize: '1rem', + fontWeight: 'normal', + minWidth: '210px', + justifyContent: 'flex-start', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + marginTop: theme.spacing(0.1), + '& .MuiChip-label': { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(1), + }, + '& .MuiChip-icon': { + fontSize: '1.2rem', + marginLeft: theme.spacing(0.5), + }, + }, + popper: { + zIndex: 1300, + }, + paper: { + padding: theme.spacing(2), + marginTop: theme.spacing(1), + minWidth: 300, + maxWidth: 400, + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: 0, + }, + masterCheckbox: { + padding: '7px', + marginLeft: '-9px', + marginRight: 0, + }, +})) + +const LibrarySelector = () => { + const classes = useStyles() + const dispatch = useDispatch() + const dataProvider = useDataProvider() + const translate = useTranslate() + const refresh = useRefresh() + const [anchorEl, setAnchorEl] = useState(null) + const [open, setOpen] = useState(false) + + const { userLibraries, selectedLibraries } = useSelector( + (state) => state.library, + ) + + // Load user's libraries when component mounts + const loadUserLibraries = useCallback(async () => { + const userId = localStorage.getItem('userId') + if (userId) { + try { + const { data } = await dataProvider.getOne('user', { id: userId }) + const libraries = data.libraries || [] + dispatch(setUserLibraries(libraries)) + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + 'Could not load user libraries (this may be expected for non-admin users):', + error, + ) + } + } + }, [dataProvider, dispatch]) + + // Initial load + useEffect(() => { + loadUserLibraries() + }, [loadUserLibraries]) + + // Reload user libraries when library changes occur + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh: loadUserLibraries, + }) + + // Don't render if user has no libraries or only has one library + if (!userLibraries.length || userLibraries.length === 1) { + return null + } + + const handleToggle = (event) => { + setAnchorEl(event.currentTarget) + const wasOpen = open + setOpen(!open) + // Refresh data when closing the dropdown + if (wasOpen) { + refresh() + } + } + + const handleClose = () => { + setOpen(false) + refresh() + } + + const handleLibraryToggle = (libraryId) => { + const newSelection = selectedLibraries.includes(libraryId) + ? selectedLibraries.filter((id) => id !== libraryId) + : [...selectedLibraries, libraryId] + + dispatch(setSelectedLibraries(newSelection)) + } + + const handleMasterCheckboxChange = () => { + if (isAllSelected) { + dispatch(setSelectedLibraries([])) + } else { + const allIds = userLibraries.map((lib) => lib.id) + dispatch(setSelectedLibraries(allIds)) + } + } + + const selectedCount = selectedLibraries.length + const totalCount = userLibraries.length + const isAllSelected = selectedCount === totalCount + const isNoneSelected = selectedCount === 0 + const isIndeterminate = selectedCount > 0 && selectedCount < totalCount + + const displayText = isNoneSelected + ? translate('menu.librarySelector.none') + ` (0 of ${totalCount})` + : isAllSelected + ? translate('menu.librarySelector.allLibraries', { count: totalCount }) + : translate('menu.librarySelector.multipleLibraries', { + selected: selectedCount, + total: totalCount, + }) + + return ( + + } + label={displayText} + onClick={handleToggle} + onDelete={open ? handleToggle : undefined} + deleteIcon={open ? : } + variant="outlined" + className={classes.chip} + /> + + + + + + + + {translate('menu.librarySelector.selectLibraries')}: + + + + + + {userLibraries.map((library) => ( + handleLibraryToggle(library.id)} + size="small" + /> + } + label={library.name} + /> + ))} + + + + + + + ) +} + +export default LibrarySelector diff --git a/ui/src/common/LibrarySelector.test.jsx b/ui/src/common/LibrarySelector.test.jsx new file mode 100644 index 000000000..13b607887 --- /dev/null +++ b/ui/src/common/LibrarySelector.test.jsx @@ -0,0 +1,517 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import LibrarySelector from './LibrarySelector' + +// Mock dependencies +const mockDispatch = vi.fn() +const mockDataProvider = { + getOne: vi.fn(), +} +const mockIdentity = { username: 'testuser' } +const mockRefresh = vi.fn() +const mockTranslate = vi.fn((key, options = {}) => { + const translations = { + 'menu.librarySelector.allLibraries': `All Libraries (${options.count || 0})`, + 'menu.librarySelector.multipleLibraries': `${options.selected || 0} of ${options.total || 0} Libraries`, + 'menu.librarySelector.none': 'None', + 'menu.librarySelector.selectLibraries': 'Select Libraries', + } + return translations[key] || key +}) + +vi.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: vi.fn(), +})) + +vi.mock('react-admin', () => ({ + useDataProvider: () => mockDataProvider, + useGetIdentity: () => ({ identity: mockIdentity }), + useTranslate: () => mockTranslate, + useRefresh: () => mockRefresh, +})) + +// Mock Material-UI components +vi.mock('@material-ui/core', () => ({ + Box: ({ children, className, ...props }) => ( +
+ {children} +
+ ), + Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => ( + + ), + ClickAwayListener: ({ children, onClickAway }) => ( +
+ {children} +
+ ), + Collapse: ({ children, in: inProp }) => + inProp ?
{children}
: null, + FormControl: ({ children }) =>
{children}
, + FormGroup: ({ children }) =>
{children}
, + FormControlLabel: ({ control, label }) => ( + + ), + Checkbox: ({ + checked, + indeterminate, + onChange, + size, + className, + ...props + }) => ( + { + if (el) el.indeterminate = indeterminate + }} + onChange={onChange} + className={className} + {...props} + /> + ), + Typography: ({ children, variant, ...props }) => ( + {children} + ), + Paper: ({ children, className }) => ( +
{children}
+ ), + Popper: ({ open, children, anchorEl, placement, className }) => + open ? ( +
+ {children} +
+ ) : null, + makeStyles: (styles) => () => { + if (typeof styles === 'function') { + return styles({ + spacing: (value) => `${value * 8}px`, + palette: { divider: '#ccc' }, + shape: { borderRadius: 4 }, + }) + } + return styles + }, +})) + +vi.mock('@material-ui/icons', () => ({ + ExpandMore: () => , + ExpandLess: () => , + LibraryMusic: () => 🎵, +})) + +// Mock actions +vi.mock('../actions', () => ({ + setSelectedLibraries: (libraries) => ({ + type: 'SET_SELECTED_LIBRARIES', + data: libraries, + }), + setUserLibraries: (libraries) => ({ + type: 'SET_USER_LIBRARIES', + data: libraries, + }), +})) + +describe('LibrarySelector', () => { + const mockLibraries = [ + { id: '1', name: 'Music Library', path: '/music' }, + { id: '2', name: 'Podcasts', path: '/podcasts' }, + { id: '3', name: 'Audiobooks', path: '/audiobooks' }, + ] + + const defaultState = { + userLibraries: mockLibraries, + selectedLibraries: ['1'], + } + + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + mockDataProvider.getOne.mockResolvedValue({ + data: { libraries: mockLibraries }, + }) + // Setup localStorage mock + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => null), // Default to null to prevent API calls + setItem: vi.fn(), + }, + writable: true, + }) + }) + + const renderLibrarySelector = (selectorState = defaultState) => { + mockUseSelector.mockImplementation((selector) => + selector({ library: selectorState }), + ) + + return render() + } + + describe('when user has no libraries', () => { + it('should not render anything', () => { + const { container } = renderLibrarySelector({ + userLibraries: [], + selectedLibraries: [], + }) + expect(container.firstChild).toBeNull() + }) + }) + + describe('when user has only one library', () => { + it('should not render anything', () => { + const singleLibrary = [mockLibraries[0]] + const { container } = renderLibrarySelector({ + userLibraries: singleLibrary, + selectedLibraries: ['1'], + }) + expect(container.firstChild).toBeNull() + }) + }) + + describe('when user has multiple libraries', () => { + it('should render the chip with correct label when one library is selected', () => { + renderLibrarySelector() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText('1 of 3 Libraries')).toBeInTheDocument() + expect(screen.getByTestId('library-music')).toBeInTheDocument() + expect(screen.getByTestId('expand-more')).toBeInTheDocument() + }) + + it('should render the chip with "All Libraries" when all libraries are selected', () => { + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '2', '3'], + }) + + expect(screen.getByText('All Libraries (3)')).toBeInTheDocument() + }) + + it('should render the chip with "None" when no libraries are selected', () => { + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: [], + }) + + expect(screen.getByText('None (0 of 3)')).toBeInTheDocument() + }) + + it('should show expand less icon when dropdown is open', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + expect(screen.getByTestId('expand-less')).toBeInTheDocument() + }) + + it('should open dropdown when chip is clicked', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + expect(screen.getByTestId('popper')).toBeInTheDocument() + expect(screen.getByText('Select Libraries:')).toBeInTheDocument() + }) + + it('should display all library names in dropdown', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + expect(screen.getByText('Music Library')).toBeInTheDocument() + expect(screen.getByText('Podcasts')).toBeInTheDocument() + expect(screen.getByText('Audiobooks')).toBeInTheDocument() + }) + + it('should not display library paths', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + expect(screen.queryByText('/music')).not.toBeInTheDocument() + expect(screen.queryByText('/podcasts')).not.toBeInTheDocument() + expect(screen.queryByText('/audiobooks')).not.toBeInTheDocument() + }) + + describe('master checkbox', () => { + it('should be checked when all libraries are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '2', '3'], + }) + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] // First checkbox is the master checkbox + expect(masterCheckbox.checked).toBe(true) + expect(masterCheckbox.indeterminate).toBe(false) + }) + + it('should be unchecked when no libraries are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: [], + }) + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] + expect(masterCheckbox.checked).toBe(false) + expect(masterCheckbox.indeterminate).toBe(false) + }) + + it('should be indeterminate when some libraries are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + }) + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] + expect(masterCheckbox.checked).toBe(false) + expect(masterCheckbox.indeterminate).toBe(true) + }) + + it('should select all libraries when clicked and none are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: [], + }) + + // Clear the dispatch mock after initial mount (it sets user libraries) + mockDispatch.mockClear() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] + + // Use fireEvent.click to trigger the onChange event + fireEvent.click(masterCheckbox) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_LIBRARIES', + data: ['1', '2', '3'], + }) + }) + + it('should deselect all libraries when clicked and all are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '2', '3'], + }) + + // Clear the dispatch mock after initial mount (it sets user libraries) + mockDispatch.mockClear() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] + + fireEvent.click(masterCheckbox) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_LIBRARIES', + data: [], + }) + }) + + it('should select all libraries when clicked and some are selected', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1'], + }) + + // Clear the dispatch mock after initial mount (it sets user libraries) + mockDispatch.mockClear() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] + + fireEvent.click(masterCheckbox) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_LIBRARIES', + data: ['1', '2', '3'], + }) + }) + }) + + describe('individual library checkboxes', () => { + it('should show correct checked state for each library', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '3'], + }) + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + // Skip master checkbox (index 0) + expect(checkboxes[1].checked).toBe(true) // Music Library + expect(checkboxes[2].checked).toBe(false) // Podcasts + expect(checkboxes[3].checked).toBe(true) // Audiobooks + }) + + it('should toggle library selection when individual checkbox is clicked', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + // Clear the dispatch mock after initial mount (it sets user libraries) + mockDispatch.mockClear() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const podcastsCheckbox = checkboxes[2] // Podcasts checkbox + + fireEvent.click(podcastsCheckbox) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_LIBRARIES', + data: ['1', '2'], + }) + }) + + it('should remove library from selection when clicking checked library', async () => { + const user = userEvent.setup() + renderLibrarySelector({ + userLibraries: mockLibraries, + selectedLibraries: ['1', '2'], + }) + + // Clear the dispatch mock after initial mount (it sets user libraries) + mockDispatch.mockClear() + + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + const checkboxes = screen.getAllByRole('checkbox') + const musicCheckbox = checkboxes[1] // Music Library checkbox + + fireEvent.click(musicCheckbox) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_LIBRARIES', + data: ['2'], + }) + }) + }) + + it('should close dropdown when clicking away', async () => { + const user = userEvent.setup() + renderLibrarySelector() + + // Open dropdown + const chipButton = screen.getByRole('button') + await user.click(chipButton) + + expect(screen.getByTestId('popper')).toBeInTheDocument() + + // Click away + const clickAwayListener = screen.getByTestId('click-away-listener') + fireEvent.mouseDown(clickAwayListener) + + await waitFor(() => { + expect(screen.queryByTestId('popper')).not.toBeInTheDocument() + }) + + // Should trigger refresh when closing + expect(mockRefresh).toHaveBeenCalledTimes(1) + }) + + it('should load user libraries on mount', async () => { + // Override localStorage mock to return a userId for this test + window.localStorage.getItem.mockReturnValue('user123') + + mockDataProvider.getOne.mockResolvedValue({ + data: { libraries: mockLibraries }, + }) + + renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) + + await waitFor(() => { + expect(mockDataProvider.getOne).toHaveBeenCalledWith('user', { + id: 'user123', + }) + }) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_USER_LIBRARIES', + data: mockLibraries, + }) + }) + + it('should handle API error gracefully', async () => { + // Override localStorage mock to return a userId for this test + window.localStorage.getItem.mockReturnValue('user123') + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockDataProvider.getOne.mockRejectedValue(new Error('API Error')) + + renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Could not load user libraries (this may be expected for non-admin users):', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should not load libraries when userId is not available', () => { + window.localStorage.getItem.mockReturnValue(null) + + renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) + + expect(mockDataProvider.getOne).not.toHaveBeenCalled() + }) + }) +}) diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx new file mode 100644 index 000000000..0ac9783f5 --- /dev/null +++ b/ui/src/common/SelectLibraryInput.jsx @@ -0,0 +1,228 @@ +import React, { useState, useEffect, useMemo } from 'react' +import Checkbox from '@material-ui/core/Checkbox' +import CheckBoxIcon from '@material-ui/icons/CheckBox' +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' +import { + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, + Box, +} from '@material-ui/core' +import { useGetList, useTranslate } from 'react-admin' +import PropTypes from 'prop-types' +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles((theme) => ({ + root: { + width: '960px', + maxWidth: '100%', + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(1), + paddingLeft: theme.spacing(1), + }, + masterCheckbox: { + padding: '7px', + marginLeft: '-9px', + marginRight: theme.spacing(1), + }, + libraryList: { + height: '120px', + overflow: 'auto', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + }, + listItem: { + paddingTop: 0, + paddingBottom: 0, + }, + emptyMessage: { + padding: theme.spacing(2), + textAlign: 'center', + color: theme.palette.text.secondary, + }, +})) + +const EmptyLibraryMessage = () => { + const classes = useStyles() + + return ( +
+ No libraries available +
+ ) +} + +const LibraryListItem = ({ library, isSelected, onToggle }) => { + const classes = useStyles() + + return ( + onToggle(library)} + dense + > + + } + checkedIcon={} + checked={isSelected} + tabIndex={-1} + disableRipple + /> + + + + ) +} + +export const SelectLibraryInput = ({ + onChange, + value = [], + isNewUser = false, +}) => { + const classes = useStyles() + const translate = useTranslate() + const [selectedLibraryIds, setSelectedLibraryIds] = useState([]) + const [hasInitialized, setHasInitialized] = useState(false) + + const { ids, data, isLoading } = useGetList( + 'library', + { page: 1, perPage: -1 }, + { field: 'name', order: 'ASC' }, + ) + + const options = useMemo( + () => (ids && ids.map((id) => data[id])) || [], + [ids, data], + ) + + // Reset initialization state when isNewUser changes + useEffect(() => { + if (isNewUser) { + setHasInitialized(false) + } + }, [isNewUser]) + + // Pre-select default libraries for new users + useEffect(() => { + if ( + isNewUser && + !isLoading && + options.length > 0 && + !hasInitialized && + Array.isArray(value) && + value.length === 0 + ) { + const defaultLibraryIds = options + .filter((lib) => lib.defaultNewUsers) + .map((lib) => lib.id) + + if (defaultLibraryIds.length > 0) { + setSelectedLibraryIds(defaultLibraryIds) + onChange(defaultLibraryIds) + } + + setHasInitialized(true) + } + }, [isNewUser, isLoading, options, hasInitialized, value, onChange]) + + // Update selectedLibraryIds when value prop changes (for editing mode and pre-selection) + useEffect(() => { + // For new users, only sync from value prop if it has actual data + // This prevents empty initial state from overriding our pre-selection + if (isNewUser && Array.isArray(value) && value.length === 0) { + return + } + + if (Array.isArray(value)) { + const libraryIds = value.map((item) => + typeof item === 'object' ? item.id : item, + ) + setSelectedLibraryIds(libraryIds) + } else if (value.length === 0) { + // Handle case where value is explicitly set to empty array (for existing users) + setSelectedLibraryIds([]) + } + }, [value, isNewUser, hasInitialized]) + + const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id) + + const handleLibraryToggle = (library) => { + const isSelected = selectedLibraryIds.includes(library.id) + let newSelection + + if (isSelected) { + newSelection = selectedLibraryIds.filter((id) => id !== library.id) + } else { + newSelection = [...selectedLibraryIds, library.id] + } + + setSelectedLibraryIds(newSelection) + onChange(newSelection) + } + + const handleMasterCheckboxChange = () => { + const isAllSelected = selectedLibraryIds.length === options.length + const newSelection = isAllSelected ? [] : options.map((lib) => lib.id) + + setSelectedLibraryIds(newSelection) + onChange(newSelection) + } + + const selectedCount = selectedLibraryIds.length + const totalCount = options.length + const isAllSelected = selectedCount === totalCount && totalCount > 0 + const isIndeterminate = selectedCount > 0 && selectedCount < totalCount + + return ( +
+ {options.length > 1 && ( + + + + {translate('resources.user.message.selectAllLibraries')} + + + )} + + {options.length === 0 ? ( + + ) : ( + options.map((library) => ( + + )) + )} + +
+ ) +} + +SelectLibraryInput.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.array, + isNewUser: PropTypes.bool, +} + +LibraryListItem.propTypes = { + library: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +} diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx new file mode 100644 index 000000000..8a7e56d3e --- /dev/null +++ b/ui/src/common/SelectLibraryInput.test.jsx @@ -0,0 +1,458 @@ +import * as React from 'react' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { SelectLibraryInput } from './SelectLibraryInput' +import { useGetList } from 'react-admin' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Mock Material-UI components +vi.mock('@material-ui/core', () => ({ + List: ({ children }) =>
{children}
, + ListItem: ({ children, button, onClick, dense, className }) => ( + + ), + ListItemIcon: ({ children }) => {children}, + ListItemText: ({ primary }) => {primary}, + Typography: ({ children, variant }) => {children}, + Box: ({ children, className }) =>
{children}
, + Checkbox: ({ + checked, + indeterminate, + onChange, + size, + className, + ...props + }) => ( + { + if (el) el.indeterminate = indeterminate + }} + onChange={onChange} + className={className} + {...props} + /> + ), + makeStyles: () => () => ({}), +})) + +// Mock Material-UI icons +vi.mock('@material-ui/icons', () => ({ + CheckBox: () => , + CheckBoxOutlineBlank: () => , +})) + +// Mock the react-admin hook +vi.mock('react-admin', () => ({ + useGetList: vi.fn(), + useTranslate: vi.fn(() => (key) => key), // Simple translation mock +})) + +describe('', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + // Reset the mock before each test + mockOnChange.mockClear() + }) + + afterEach(cleanup) + + it('should render empty message when no libraries available', () => { + // Mock empty library response + useGetList.mockReturnValue({ + ids: [], + data: {}, + }) + + render() + expect(screen.getByText('No libraries available')).not.toBeNull() + }) + + it('should render libraries when available', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + render() + expect(screen.getByText('Library 1')).not.toBeNull() + expect(screen.getByText('Library 2')).not.toBeNull() + }) + + it('should toggle selection when a library is clicked', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + + // Test selecting an item + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + render() + + // Find the library buttons by their text content + const library1Button = screen.getByText('Library 1').closest('button') + fireEvent.click(library1Button) + expect(mockOnChange).toHaveBeenCalledWith(['1']) + + // Clean up to avoid DOM duplication + cleanup() + mockOnChange.mockClear() + + // Test deselecting an item + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + render() + + // Find the library button again and click to deselect + const library1ButtonDeselect = screen + .getByText('Library 1') + .closest('button') + fireEvent.click(library1ButtonDeselect) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) + + it('should correctly initialize with provided values', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + // Initial value as array of IDs + render() + + // Check that checkbox for Library 1 is checked + const checkboxes = screen.getAllByRole('checkbox') + // With master checkbox, individual checkboxes start at index 1 + expect(checkboxes[1].checked).toBe(true) // Library 1 + expect(checkboxes[2].checked).toBe(false) // Library 2 + }) + + it('should handle value as array of objects', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + // Initial value as array of objects with id property + render() + + // Check that checkbox for Library 2 is checked + const checkboxes = screen.getAllByRole('checkbox') + // With master checkbox, index shifts by 1 + expect(checkboxes[1].checked).toBe(false) // Library 1 + expect(checkboxes[2].checked).toBe(true) // Library 2 + }) + + it('should render master checkbox when there are multiple libraries', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + render() + + // Should render master checkbox plus individual checkboxes + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes).toHaveLength(3) // 1 master + 2 individual + expect( + screen.getByText('resources.user.message.selectAllLibraries'), + ).not.toBeNull() + }) + + it('should not render master checkbox when there is only one library', () => { + // Mock single library + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + } + useGetList.mockReturnValue({ + ids: ['1'], + data: mockLibraries, + }) + + render() + + // Should render only individual checkbox + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes).toHaveLength(1) // Only 1 individual checkbox + }) + + it('should handle master checkbox selection and deselection', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + render() + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] // Master is first + + // Click master checkbox to select all + fireEvent.click(masterCheckbox) + expect(mockOnChange).toHaveBeenCalledWith(['1', '2']) + + // Clean up and test deselect all + cleanup() + mockOnChange.mockClear() + + render() + const checkboxes2 = screen.getAllByRole('checkbox') + const masterCheckbox2 = checkboxes2[0] + + // Click master checkbox to deselect all + fireEvent.click(masterCheckbox2) + expect(mockOnChange).toHaveBeenCalledWith([]) + }) + + it('should show master checkbox as indeterminate when some libraries are selected', () => { + // Mock libraries + const mockLibraries = { + 1: { id: '1', name: 'Library 1' }, + 2: { id: '2', name: 'Library 2' }, + } + useGetList.mockReturnValue({ + ids: ['1', '2'], + data: mockLibraries, + }) + + render() + + const checkboxes = screen.getAllByRole('checkbox') + const masterCheckbox = checkboxes[0] // Master is first + + // Master checkbox should not be checked when only some libraries are selected + expect(masterCheckbox.checked).toBe(false) + // Note: Testing indeterminate property directly through JSDOM can be unreliable + // The important behavior is that it's not checked when only some are selected + }) + + describe('New User Default Library Selection', () => { + // Helper function to create mock libraries with configurable default settings + const createMockLibraries = (libraryConfigs) => { + const libraries = {} + const ids = [] + + libraryConfigs.forEach(({ id, name, defaultNewUsers }) => { + libraries[id] = { + id, + name, + ...(defaultNewUsers !== undefined && { defaultNewUsers }), + } + ids.push(id) + }) + + return { libraries, ids } + } + + // Helper function to setup useGetList mock + const setupMockLibraries = (libraryConfigs, isLoading = false) => { + const { libraries, ids } = createMockLibraries(libraryConfigs) + useGetList.mockReturnValue({ + ids, + data: libraries, + isLoading, + }) + return { libraries, ids } + } + + beforeEach(() => { + mockOnChange.mockClear() + }) + + it('should pre-select default libraries for new users', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + { id: '2', name: 'Library 2', defaultNewUsers: false }, + { id: '3', name: 'Library 3', defaultNewUsers: true }, + ]) + + render( + , + ) + + expect(mockOnChange).toHaveBeenCalledWith(['1', '3']) + }) + + it('should not pre-select default libraries if new user already has values', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + { id: '2', name: 'Library 2', defaultNewUsers: false }, + ]) + + render( + , + ) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should not pre-select libraries while data is still loading', () => { + setupMockLibraries( + [{ id: '1', name: 'Library 1', defaultNewUsers: true }], + true, + ) // isLoading = true + + render( + , + ) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should not pre-select anything if no libraries have defaultNewUsers flag', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: false }, + { id: '2', name: 'Library 2', defaultNewUsers: false }, + ]) + + render( + , + ) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should reset initialization state when isNewUser prop changes', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + ]) + + const { rerender } = render( + , + ) + + expect(mockOnChange).not.toHaveBeenCalled() + + // Change to new user + rerender( + , + ) + + expect(mockOnChange).toHaveBeenCalledWith(['1']) + }) + + it('should not override pre-selection when value prop is empty for new users', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + { id: '2', name: 'Library 2', defaultNewUsers: false }, + ]) + + const { rerender } = render( + , + ) + + expect(mockOnChange).toHaveBeenCalledWith(['1']) + mockOnChange.mockClear() + + // Re-render with empty value prop (simulating form state update) + rerender( + , + ) + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should sync from value prop for existing users even when empty', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + ]) + + render( + , + ) + + // Check that no libraries are selected (checkboxes should be unchecked) + const checkboxes = screen.getAllByRole('checkbox') + // Only one checkbox since there's only one library and no master checkbox for single library + expect(checkboxes[0].checked).toBe(false) + }) + + it('should handle libraries with missing defaultNewUsers property', () => { + setupMockLibraries([ + { id: '1', name: 'Library 1', defaultNewUsers: true }, + { id: '2', name: 'Library 2' }, // Missing defaultNewUsers property + { id: '3', name: 'Library 3', defaultNewUsers: false }, + ]) + + render( + , + ) + + expect(mockOnChange).toHaveBeenCalledWith(['1']) + }) + }) +}) diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 9b9ca18cd..1b1a014f1 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -59,6 +59,7 @@ export const SongInfo = (props) => { ] const data = { path: , + libraryName: , album: ( ), diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 1a43047c1..f64d4fe0c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -27,6 +27,7 @@ export * from './useAlbumsPerPage' export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' +export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' diff --git a/ui/src/common/useLibrarySelection.js b/ui/src/common/useLibrarySelection.js new file mode 100644 index 000000000..c5d84a61f --- /dev/null +++ b/ui/src/common/useLibrarySelection.js @@ -0,0 +1,44 @@ +import { useSelector } from 'react-redux' + +/** + * Hook to get the currently selected library IDs + * Returns an array of library IDs that should be used for filtering data + * If no libraries are selected (empty array), returns all user accessible libraries + */ +export const useSelectedLibraries = () => { + const { userLibraries, selectedLibraries } = useSelector( + (state) => state.library, + ) + + // If no specific selection, default to all accessible libraries + if (selectedLibraries.length === 0 && userLibraries.length > 0) { + return userLibraries.map((lib) => lib.id) + } + + return selectedLibraries +} + +/** + * Hook to get library filter parameters for data provider queries + * Returns an object that can be spread into query parameters + */ +export const useLibraryFilter = () => { + const selectedLibraryIds = useSelectedLibraries() + + // If user has access to only one library or no specific selection, no filter needed + if (selectedLibraryIds.length <= 1) { + return {} + } + + return { + libraryIds: selectedLibraryIds, + } +} + +/** + * Hook to check if a specific library is currently selected + */ +export const useIsLibrarySelected = (libraryId) => { + const selectedLibraryIds = useSelectedLibraries() + return selectedLibraryIds.includes(libraryId) +} diff --git a/ui/src/common/useLibrarySelection.test.js b/ui/src/common/useLibrarySelection.test.js new file mode 100644 index 000000000..30f109dc6 --- /dev/null +++ b/ui/src/common/useLibrarySelection.test.js @@ -0,0 +1,204 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + useSelectedLibraries, + useLibraryFilter, + useIsLibrarySelected, +} from './useLibrarySelection' + +// Mock dependencies +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) + +describe('Library Selection Hooks', () => { + const mockLibraries = [ + { id: '1', name: 'Music Library' }, + { id: '2', name: 'Podcasts' }, + { id: '3', name: 'Audiobooks' }, + ] + + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + }) + + const setupSelector = ( + userLibraries = mockLibraries, + selectedLibraries = [], + ) => { + mockUseSelector.mockImplementation((selector) => + selector({ + library: { + userLibraries, + selectedLibraries, + }, + }), + ) + } + + describe('useSelectedLibraries', () => { + it('should return selected library IDs when libraries are explicitly selected', async () => { + setupSelector(mockLibraries, ['1', '2']) + + const { result } = renderHook(() => useSelectedLibraries()) + + expect(result.current).toEqual(['1', '2']) + }) + + it('should return all user library IDs when no libraries are selected and user has libraries', async () => { + setupSelector(mockLibraries, []) + + const { result } = renderHook(() => useSelectedLibraries()) + + expect(result.current).toEqual(['1', '2', '3']) + }) + + it('should return empty array when no libraries are selected and user has no libraries', async () => { + setupSelector([], []) + + const { result } = renderHook(() => useSelectedLibraries()) + + expect(result.current).toEqual([]) + }) + + it('should return selected libraries even if they are all user libraries', async () => { + setupSelector(mockLibraries, ['1', '2', '3']) + + const { result } = renderHook(() => useSelectedLibraries()) + + expect(result.current).toEqual(['1', '2', '3']) + }) + + it('should return single selected library', async () => { + setupSelector(mockLibraries, ['2']) + + const { result } = renderHook(() => useSelectedLibraries()) + + expect(result.current).toEqual(['2']) + }) + }) + + describe('useLibraryFilter', () => { + it('should return empty object when user has only one library', async () => { + setupSelector([mockLibraries[0]], ['1']) + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({}) + }) + + it('should return empty object when no libraries are selected (defaults to all)', async () => { + setupSelector([mockLibraries[0]], []) + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({}) + }) + + it('should return libraryIds filter when multiple libraries are available and some are selected', async () => { + setupSelector(mockLibraries, ['1', '2']) + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({ + libraryIds: ['1', '2'], + }) + }) + + it('should return libraryIds filter when multiple libraries are available and all are selected', async () => { + setupSelector(mockLibraries, ['1', '2', '3']) + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({ + libraryIds: ['1', '2', '3'], + }) + }) + + it('should return empty object when user has no libraries', async () => { + setupSelector([], []) + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({}) + }) + + it('should return libraryIds filter for default selection when multiple libraries available', async () => { + setupSelector(mockLibraries, []) // No explicit selection, should default to all + + const { result } = renderHook(() => useLibraryFilter()) + + expect(result.current).toEqual({ + libraryIds: ['1', '2', '3'], + }) + }) + }) + + describe('useIsLibrarySelected', () => { + it('should return true when library is explicitly selected', async () => { + setupSelector(mockLibraries, ['1', '3']) + + const { result: result1 } = renderHook(() => useIsLibrarySelected('1')) + const { result: result2 } = renderHook(() => useIsLibrarySelected('3')) + + expect(result1.current).toBe(true) + expect(result2.current).toBe(true) + }) + + it('should return false when library is not explicitly selected', async () => { + setupSelector(mockLibraries, ['1', '3']) + + const { result } = renderHook(() => useIsLibrarySelected('2')) + + expect(result.current).toBe(false) + }) + + it('should return true when no explicit selection (defaults to all) and library exists', async () => { + setupSelector(mockLibraries, []) + + const { result: result1 } = renderHook(() => useIsLibrarySelected('1')) + const { result: result2 } = renderHook(() => useIsLibrarySelected('2')) + const { result: result3 } = renderHook(() => useIsLibrarySelected('3')) + + expect(result1.current).toBe(true) + expect(result2.current).toBe(true) + expect(result3.current).toBe(true) + }) + + it('should return false when library does not exist in user libraries', async () => { + setupSelector(mockLibraries, []) + + const { result } = renderHook(() => useIsLibrarySelected('999')) + + expect(result.current).toBe(false) + }) + + it('should return false when user has no libraries', async () => { + setupSelector([], []) + + const { result } = renderHook(() => useIsLibrarySelected('1')) + + expect(result.current).toBe(false) + }) + + it('should handle undefined libraryId', async () => { + setupSelector(mockLibraries, ['1']) + + const { result } = renderHook(() => useIsLibrarySelected(undefined)) + + expect(result.current).toBe(false) + }) + + it('should handle null libraryId', async () => { + setupSelector(mockLibraries, ['1']) + + const { result } = renderHook(() => useIsLibrarySelected(null)) + + expect(result.current).toBe(false) + }) + }) +}) diff --git a/ui/src/common/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx new file mode 100644 index 000000000..b5f1b1ede --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +/** + * A reusable hook for triggering custom reload logic when specific SSE events occur. + * + * This hook is ideal when: + * - Your component displays derived/related data that isn't directly managed by react-admin + * - You need custom loading logic that goes beyond simple dataProvider.getMany() calls + * - Your data comes from non-standard endpoints or requires special processing + * - You want to reload parent/related resources when child resources change + * + * @param {Object} options - Configuration options + * @param {Array} options.events - Array of event types to listen for (e.g., ['library', 'user', '*']) + * @param {Function} options.onRefresh - Async function to call when events occur. + * Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders. + * + * @example + * // Example 1: LibrarySelector - Reload user data when library changes + * const loadUserLibraries = useCallback(async () => { + * const userId = localStorage.getItem('userId') + * if (userId) { + * const { data } = await dataProvider.getOne('user', { id: userId }) + * dispatch(setUserLibraries(data.libraries || [])) + * } + * }, [dataProvider, dispatch]) + * + * useRefreshOnEvents({ + * events: ['library', 'user'], + * onRefresh: loadUserLibraries + * }) + * + * @example + * // Example 2: Statistics Dashboard - Reload stats when any music data changes + * const loadStats = useCallback(async () => { + * const stats = await dataProvider.customRequest('GET', 'stats') + * setDashboardStats(stats) + * }, [dataProvider, setDashboardStats]) + * + * useRefreshOnEvents({ + * events: ['album', 'song', 'artist'], + * onRefresh: loadStats + * }) + * + * @example + * // Example 3: Permission-based UI - Reload permissions when user changes + * const loadPermissions = useCallback(async () => { + * const authData = await authProvider.getPermissions() + * setUserPermissions(authData) + * }, [authProvider, setUserPermissions]) + * + * useRefreshOnEvents({ + * events: ['user'], + * onRefresh: loadPermissions + * }) + * + * @example + * // Example 4: Listen to all events (use sparingly) + * const reloadAll = useCallback(async () => { + * // This will trigger on ANY refresh event + * await reloadEverything() + * }, [reloadEverything]) + * + * useRefreshOnEvents({ + * events: ['*'], + * onRefresh: reloadAll + * }) + */ +export const useRefreshOnEvents = ({ events, onRefresh }) => { + const [lastRefreshTime, setLastRefreshTime] = useState(Date.now()) + + const refreshData = useSelector( + (state) => state.activity?.refresh || { lastReceived: lastRefreshTime }, + ) + + useEffect(() => { + const { resources, lastReceived } = refreshData + + // Only process if we have new events + if (lastReceived <= lastRefreshTime) { + return + } + + // Check if any of the events we're interested in occurred + const shouldRefresh = + resources && + // Global refresh event + (resources['*'] === '*' || + // Check for specific events we're listening to + events.some((eventType) => { + if (eventType === '*') { + return true // Listen to all events + } + return resources[eventType] // Check if this specific event occurred + })) + + if (shouldRefresh) { + setLastRefreshTime(lastReceived) + + // Call the custom refresh function + if (onRefresh) { + onRefresh().catch((error) => { + // eslint-disable-next-line no-console + console.warn('Error in useRefreshOnEvents onRefresh callback:', error) + }) + } + } + }, [refreshData, lastRefreshTime, events, onRefresh]) +} diff --git a/ui/src/common/useRefreshOnEvents.test.js b/ui/src/common/useRefreshOnEvents.test.js new file mode 100644 index 000000000..2306cd3c9 --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.test.js @@ -0,0 +1,233 @@ +import { vi } from 'vitest' +import * as React from 'react' +import * as Redux from 'react-redux' +import { useRefreshOnEvents } from './useRefreshOnEvents' + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useState: vi.fn(), + useEffect: vi.fn(), + } +}) + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux') + return { + ...actual, + useSelector: vi.fn(), + } +}) + +describe('useRefreshOnEvents', () => { + const setState = vi.fn() + const useStateMock = (initState) => [initState, setState] + const onRefresh = vi.fn().mockResolvedValue() + let lastTime + let mockUseEffect + + beforeEach(() => { + vi.spyOn(React, 'useState').mockImplementation(useStateMock) + mockUseEffect = vi.spyOn(React, 'useEffect') + lastTime = new Date(new Date().valueOf() + 1000) + onRefresh.mockClear() + setState.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('stores last time checked, to avoid redundant runs', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, // Need some resources to trigger the update + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it("does not run again if lastTime didn't change", () => { + vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) + const useSelectorMock = () => ({ lastReceived: lastTime }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(setState).not.toHaveBeenCalled() + expect(onRefresh).not.toHaveBeenCalled() + }) + + describe('Event listening and refresh triggering', () => { + beforeEach(() => { + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + }) + + it('triggers refresh when a watched event occurs', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1', 'lib-2'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('triggers refresh when multiple watched events occur', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { + library: ['lib-1'], + user: ['user-1'], + album: ['album-1'], // This shouldn't trigger since it's not watched + }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('does not trigger refresh when unwatched events occur', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['album-1'], song: ['song-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh, + }) + + expect(onRefresh).not.toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) + + it('triggers refresh on global refresh event', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('triggers refresh when listening to all events with "*"', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { song: ['song-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['*'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('handles empty events array gracefully', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: [], + onRefresh, + }) + + expect(onRefresh).not.toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) + + it('handles missing onRefresh function gracefully', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + expect(() => { + useRefreshOnEvents({ + events: ['library'], + // onRefresh is undefined + }) + }).not.toThrow() + + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('handles onRefresh errors gracefully', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) + const failingRefresh = vi + .fn() + .mockRejectedValue(new Error('Refresh failed')) + + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh: failingRefresh, + }) + + expect(failingRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + + // Wait for the promise to be rejected and handled + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Error in useRefreshOnEvents onRefresh callback:', + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + }) +}) diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx index d9f6aee52..eabff6f92 100644 --- a/ui/src/common/useResourceRefresh.jsx +++ b/ui/src/common/useResourceRefresh.jsx @@ -2,6 +2,67 @@ import { useSelector } from 'react-redux' import { useState } from 'react' import { useRefresh, useDataProvider } from 'react-admin' +/** + * A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE. + * + * This hook is designed for components that display react-admin managed resources (like lists, shows, edits) + * and need to stay in sync when those resources are modified elsewhere in the application. + * + * **When to use this hook:** + * - Your component displays react-admin resources (albums, songs, artists, playlists, etc.) + * - You want automatic refresh when those resources are created/updated/deleted + * - Your data comes from standard dataProvider.getMany() calls + * - You're using react-admin's data management (queries, mutations, caching) + * + * **When NOT to use this hook:** + * - Your component displays derived/custom data not directly managed by react-admin + * - You need custom reload logic beyond dataProvider.getMany() + * - Your data comes from non-standard endpoints + * - Use `useRefreshOnEvents` instead for these scenarios + * + * @param {...string} visibleResources - Resource names to watch for changes. + * If no resources specified, watches all resources. + * If '*' is included in resources, triggers full page refresh. + * + * @example + * // Example 1: Album list - refresh when albums change + * const AlbumList = () => { + * useResourceRefresh('album') + * return ... + * } + * + * @example + * // Example 2: Album show page - refresh when album or its songs change + * const AlbumShow = () => { + * useResourceRefresh('album', 'song') + * return ... + * } + * + * @example + * // Example 3: Dashboard - refresh when any resource changes + * const Dashboard = () => { + * useResourceRefresh() // No parameters = watch all resources + * return
...
+ * } + * + * @example + * // Example 4: Library management page - watch library resources + * const LibraryList = () => { + * useResourceRefresh('library') + * return ... + * } + * + * **How it works:** + * - Listens to refresh events from the SSE connection + * - When events arrive, checks if they match the specified visible resources + * - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]}) + * - For global refreshes: calls refresh() to reload the entire page + * - Uses react-admin's built-in data management and caching + * + * **Event format expected:** + * - Global refresh: { '*': '*' } or { someResource: ['*'] } + * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } + */ export const useResourceRefresh = (...visibleResources) => { const [lastTime, setLastTime] = useState(Date.now()) const refresh = useRefresh() diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 257a274e8..8b4a0cb62 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -9,25 +9,59 @@ const isAdmin = () => { return role === 'admin' } +const getSelectedLibraries = () => { + try { + const state = JSON.parse(localStorage.getItem('state')) + return state?.library?.selectedLibraries || [] + } catch (err) { + return [] + } +} + +// Function to apply library filtering to appropriate resources +const applyLibraryFilter = (resource, params) => { + // Content resources that should be filtered by selected libraries + const filteredResources = ['album', 'song', 'artist', 'playlistTrack', 'tag'] + + // Get selected libraries from localStorage + const selectedLibraries = getSelectedLibraries() + + // Add library filter for content resources if libraries are selected + if (filteredResources.includes(resource) && selectedLibraries.length > 0) { + if (!params.filter) { + params.filter = {} + } + params.filter.library_id = selectedLibraries + } + + return params +} + const mapResource = (resource, params) => { switch (resource) { + // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks case 'playlistTrack': { - // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks + params.filter = params.filter || {} + let plsId = '0' - if (params.filter) { - plsId = params.filter.playlist_id - if (!isAdmin()) { - params.filter.missing = false - } + plsId = params.filter.playlist_id + if (!isAdmin()) { + params.filter.missing = false } + params = applyLibraryFilter(resource, params) + return [`playlist/${plsId}/tracks`, params] } case 'album': case 'song': - case 'artist': { - if (params.filter && !isAdmin()) { + case 'artist': + case 'tag': { + params.filter = params.filter || {} + if (!isAdmin()) { params.filter.missing = false } + params = applyLibraryFilter(resource, params) + return [resource, params] } default: @@ -43,6 +77,60 @@ const callDeleteMany = (resource, params) => { }).then((response) => ({ data: response.json.ids || [] })) } +// Helper function to handle user-library associations +const handleUserLibraryAssociation = async (userId, libraryIds) => { + if (!libraryIds || libraryIds.length === 0) { + return // Admin users or users without library assignments + } + + try { + await httpClient(`${REST_URL}/user/${userId}/library`, { + method: 'PUT', + body: JSON.stringify({ libraryIds }), + }) + } catch (error) { + console.error('Error setting user libraries:', error) //eslint-disable-line no-console + throw error + } +} + +// Enhanced user creation that handles library associations +const createUser = async (params) => { + const { data } = params + const { libraryIds, ...userData } = data + + // First create the user + const userResponse = await dataProvider.create('user', { data: userData }) + const userId = userResponse.data.id + + // Then set library associations for non-admin users + if (!userData.isAdmin && libraryIds && libraryIds.length > 0) { + await handleUserLibraryAssociation(userId, libraryIds) + } + + return userResponse +} + +// Enhanced user update that handles library associations +const updateUser = async (params) => { + const { data } = params + const { libraryIds, ...userData } = data + const userId = params.id + + // First update the user + const userResponse = await dataProvider.update('user', { + ...params, + data: userData, + }) + + // Then handle library associations for non-admin users + if (!userData.isAdmin && libraryIds !== undefined) { + await handleUserLibraryAssociation(userId, libraryIds) + } + + return userResponse +} + const wrapperDataProvider = { ...dataProvider, getList: (resource, params) => { @@ -51,7 +139,19 @@ const wrapperDataProvider = { }, getOne: (resource, params) => { const [r, p] = mapResource(resource, params) - return dataProvider.getOne(r, p) + const response = dataProvider.getOne(r, p) + + // Transform user data to ensure libraryIds is present for form compatibility + if (resource === 'user') { + return response.then((result) => { + if (result.data.libraries && Array.isArray(result.data.libraries)) { + result.data.libraryIds = result.data.libraries.map((lib) => lib.id) + } + return result + }) + } + + return response }, getMany: (resource, params) => { const [r, p] = mapResource(resource, params) @@ -62,6 +162,9 @@ const wrapperDataProvider = { return dataProvider.getManyReference(r, p) }, update: (resource, params) => { + if (resource === 'user') { + return updateUser(params) + } const [r, p] = mapResource(resource, params) return dataProvider.update(r, p) }, @@ -70,6 +173,9 @@ const wrapperDataProvider = { return dataProvider.updateMany(r, p) }, create: (resource, params) => { + if (resource === 'user') { + return createUser(params) + } const [r, p] = mapResource(resource, params) return dataProvider.create(r, p) }, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 6b647d213..f384df2d2 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -12,6 +12,7 @@ "artist": "Artist", "album": "Album", "path": "File path", + "libraryName": "Library", "genre": "Genre", "compilation": "Compilation", "year": "Year", @@ -58,6 +59,7 @@ "playCount": "Plays", "size": "Size", "name": "Name", + "libraryName": "Library", "genre": "Genre", "compilation": "Compilation", "year": "Year", @@ -147,19 +149,26 @@ "changePassword": "Change Password?", "currentPassword": "Current Password", "newPassword": "New Password", - "token": "Token" + "token": "Token", + "libraries": "Libraries" }, "helperTexts": { - "name": "Changes to your name will only be reflected on next login" + "name": "Changes to your name will only be reflected on next login", + "libraries": "Select specific libraries for this user, or leave empty to use default libraries" }, "notifications": { "created": "User created", "updated": "User updated", "deleted": "User deleted" }, + "validation": { + "librariesRequired": "At least one library must be selected for non-admin users" + }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", - "clickHereForToken": "Click here to get your token" + "clickHereForToken": "Click here to get your token", + "selectAllLibraries": "Select all libraries", + "adminAutoLibraries": "Admin users automatically have access to all libraries" } }, "player": { @@ -254,6 +263,7 @@ "fields": { "path": "Path", "size": "Size", + "libraryName": "Library", "updatedAt": "Disappeared on" }, "actions": { @@ -263,6 +273,63 @@ "notifications": { "removed": "Missing file(s) removed" } + }, + "library": { + "name": "Library |||| Libraries", + "fields": { + "name": "Name", + "path": "Path", + "remotePath": "Remote Path", + "lastScanAt": "Last Scan", + "songCount": "Songs", + "albumCount": "Albums", + "artistCount": "Artists", + "scanCount": "Scan Count", + "missingFileCount": "Missing Files", + "size": "Size", + "duration": "Duration", + "totalSongs": "Songs", + "totalAlbums": "Albums", + "totalArtists": "Artists", + "totalFolders": "Folders", + "totalFiles": "Files", + "totalMissingFiles": "Missing Files", + "totalSize": "Total Size", + "totalDuration": "Duration", + "defaultNewUsers": "Default for New Users", + "createdAt": "Created", + "updatedAt": "Updated" + }, + "sections": { + "basic": "Basic Information", + "statistics": "Statistics", + "scan": "Scan Information" + }, + "actions": { + "scan": "Scan Library", + "manageUsers": "Manage User Access", + "viewDetails": "View Details" + }, + "notifications": { + "created": "Library created successfully", + "updated": "Library updated successfully", + "deleted": "Library deleted successfully", + "scanStarted": "Library scan started", + "scanCompleted": "Library scan completed" + }, + "validation": { + "nameRequired": "Library name is required", + "pathRequired": "Library path is required", + "pathNotDirectory": "Library path must be a directory", + "pathNotFound": "Library path not found", + "pathNotAccessible": "Library path is not accessible", + "pathInvalid": "Invalid library path" + }, + "messages": { + "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", + "scanInProgress": "Scan in progress...", + "noLibrariesAssigned": "No libraries assigned to this user" + } } }, "ra": { @@ -450,6 +517,12 @@ }, "menu": { "library": "Library", + "librarySelector": { + "allLibraries": "All Libraries (%{count})", + "multipleLibraries": "%{selected} of %{total} Libraries", + "selectLibraries": "Select Libraries", + "none": "None" + }, "settings": "Settings", "version": "Version", "theme": "Theme", diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index bd1e37ee0..45f40b26d 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -9,6 +9,7 @@ import SubMenu from './SubMenu' import { humanize, pluralize } from 'inflection' import albumLists from '../album/albumLists' import PlaylistsSubMenu from './PlaylistsSubMenu' +import LibrarySelector from '../common/LibrarySelector' import config from '../config' const useStyles = makeStyles((theme) => ({ @@ -111,6 +112,7 @@ const Menu = ({ dense = false }) => { [classes.closed]: !open, })} > + {open && } handleToggle('menuAlbumList')} isOpen={state.menuAlbumList} diff --git a/ui/src/library/DeleteLibraryButton.jsx b/ui/src/library/DeleteLibraryButton.jsx new file mode 100644 index 000000000..8d9ff6ed2 --- /dev/null +++ b/ui/src/library/DeleteLibraryButton.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import DeleteIcon from '@material-ui/icons/Delete' +import { makeStyles, alpha } from '@material-ui/core/styles' +import clsx from 'clsx' +import { + useNotify, + useDeleteWithConfirmController, + Button, + Confirm, + useTranslate, + useRedirect, +} from 'react-admin' + +const useStyles = makeStyles( + (theme) => ({ + deleteButton: { + color: theme.palette.error.main, + '&:hover': { + backgroundColor: alpha(theme.palette.error.main, 0.12), + // Reset on mouse devices + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + }, + }), + { name: 'RaDeleteWithConfirmButton' }, +) + +const DeleteLibraryButton = ({ + record, + resource, + basePath, + className, + ...props +}) => { + const translate = useTranslate() + const notify = useNotify() + const redirect = useRedirect() + + const onSuccess = () => { + notify('resources.library.notifications.deleted', 'info', { + smart_count: 1, + }) + redirect('/library') + } + + const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } = + useDeleteWithConfirmController({ + resource, + record, + basePath, + onSuccess, + }) + + const classes = useStyles(props) + return ( + <> + + + + ) +} + +export default DeleteLibraryButton diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx new file mode 100644 index 000000000..0e69964b6 --- /dev/null +++ b/ui/src/library/LibraryCreate.jsx @@ -0,0 +1,84 @@ +import React, { useCallback } from 'react' +import { + Create, + SimpleForm, + TextInput, + BooleanInput, + required, + useTranslate, + useMutation, + useNotify, + useRedirect, +} from 'react-admin' +import { Title } from '../common' + +const LibraryCreate = (props) => { + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const resourceName = translate('resources.library.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'create', + resource: 'library', + payload: { data: values }, + }, + { returnPromise: true }, + ) + notify('resources.library.notifications.created', 'info', { + smart_count: 1, + }) + redirect('/library') + } catch (error) { + // Handle validation errors with proper field mapping + if (error.body && error.body.errors) { + return error.body.errors + } + + // Handle other structured errors from the server + if (error.body && error.body.error) { + const errorMsg = error.body.error + + // Handle database constraint violations + if (errorMsg.includes('UNIQUE constraint failed: library.name')) { + return { name: 'ra.validation.unique' } + } + if (errorMsg.includes('UNIQUE constraint failed: library.path')) { + return { path: 'ra.validation.unique' } + } + + // Show a general notification for other server errors + notify(errorMsg, 'error') + return + } + + // Fallback for unexpected error formats + const fallbackMessage = + error.message || + (typeof error === 'string' ? error : 'An unexpected error occurred') + notify(fallbackMessage, 'error') + } + }, + [mutate, notify, redirect], + ) + + return ( + } {...props}> + + + + + + + ) +} + +export default LibraryCreate diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx new file mode 100644 index 000000000..f00fbf7c6 --- /dev/null +++ b/ui/src/library/LibraryEdit.jsx @@ -0,0 +1,274 @@ +import React, { useCallback } from 'react' +import { + Edit, + FormWithRedirect, + TextInput, + BooleanInput, + required, + SaveButton, + DateField, + useTranslate, + useMutation, + useNotify, + useRedirect, + NumberInput, + Toolbar, +} from 'react-admin' +import { Typography, Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import DeleteLibraryButton from './DeleteLibraryButton' +import { Title } from '../common' +import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js' + +const useStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}) + +const LibraryTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.library.name', { smart_count: 1 }) + return ( + + ) +} + +const CustomToolbar = ({ showDelete, ...props }) => ( + <Toolbar {...props} classes={useStyles()}> + <SaveButton disabled={props.pristine} /> + {showDelete && ( + <DeleteLibraryButton + record={props.record} + resource="library" + basePath="/library" + /> + )} + </Toolbar> +) + +const LibraryEdit = (props) => { + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + + // Library ID 1 is protected (main library) + const canDelete = props.id !== '1' + const canEditPath = props.id !== '1' + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'update', + resource: 'library', + payload: { id: values.id, data: values }, + }, + { returnPromise: true }, + ) + notify('resources.library.notifications.updated', 'info', { + smart_count: 1, + }) + redirect('/library') + } catch (error) { + if (error.body && error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, redirect], + ) + + return ( + <Edit title={<LibraryTitle />} undoable={false} {...props}> + <FormWithRedirect + {...props} + save={save} + render={(formProps) => ( + <form onSubmit={formProps.handleSubmit}> + <Box p="1em" maxWidth="800px"> + <Box display="flex"> + <Box flex={1} mr="1em"> + {/* Basic Information */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.basic')} + </Typography> + + <TextInput + source="name" + label={translate('resources.library.fields.name')} + validate={[required()]} + variant="outlined" + /> + <TextInput + source="path" + label={translate('resources.library.fields.path')} + validate={[required()]} + fullWidth + variant="outlined" + InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 + /> + <BooleanInput + source="defaultNewUsers" + label={translate( + 'resources.library.fields.defaultNewUsers', + )} + variant="outlined" + /> + + <Box mt="2em" /> + + {/* Statistics - Two Column Layout */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.statistics')} + </Typography> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <NumberInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSongs'} + label={translate('resources.library.fields.totalSongs')} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <NumberInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalAlbums'} + label={translate( + 'resources.library.fields.totalAlbums', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <NumberInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalArtists'} + label={translate( + 'resources.library.fields.totalArtists', + )} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSize'} + label={translate('resources.library.fields.totalSize')} + format={formatBytes} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalDuration'} + label={translate( + 'resources.library.fields.totalDuration', + )} + format={formatDuration2} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalMissingFiles'} + label={translate( + 'resources.library.fields.totalMissingFiles', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + {/* Timestamps Section */} + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.lastScanAt')} + </Typography> + <DateField + variant="body1" + source="lastScanAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.updatedAt')} + </Typography> + <DateField + variant="body1" + source="updatedAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="2em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.createdAt')} + </Typography> + <DateField + variant="body1" + source="createdAt" + showTime + record={formProps.record} + /> + </Box> + </Box> + </Box> + </Box> + + <CustomToolbar + handleSubmitWithRedirect={formProps.handleSubmitWithRedirect} + pristine={formProps.pristine} + saving={formProps.saving} + record={formProps.record} + showDelete={canDelete} + /> + </form> + )} + /> + </Edit> + ) +} + +export default LibraryEdit diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx new file mode 100644 index 000000000..c2d2f6295 --- /dev/null +++ b/ui/src/library/LibraryList.jsx @@ -0,0 +1,56 @@ +import React from 'react' +import { + Datagrid, + Filter, + SearchInput, + SimpleList, + TextField, + NumberField, + BooleanField, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { List, DateField, useResourceRefresh } from '../common' + +const LibraryFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput source="name" alwaysOn /> + </Filter> +) + +const LibraryList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + useResourceRefresh('library') + + return ( + <List + {...props} + sort={{ field: 'name', order: 'ASC' }} + exporter={false} + bulkActionButtons={false} + filters={<LibraryFilter />} + > + {isXsmall ? ( + <SimpleList + primaryText={(record) => record.name} + secondaryText={(record) => record.path} + /> + ) : ( + <Datagrid rowClick="edit"> + <TextField source="name" /> + <TextField source="path" /> + <BooleanField source="defaultNewUsers" /> + <NumberField source="totalSongs" label="Songs" /> + <NumberField source="totalAlbums" label="Albums" /> + <NumberField source="totalMissingFiles" label="Missing Files" /> + <DateField + source="lastScanAt" + label="Last Scan" + sortByOrder={'DESC'} + /> + </Datagrid> + )} + </List> + ) +} + +export default LibraryList diff --git a/ui/src/library/index.js b/ui/src/library/index.js new file mode 100644 index 000000000..3a8b71b52 --- /dev/null +++ b/ui/src/library/index.js @@ -0,0 +1,11 @@ +import { MdLibraryMusic } from 'react-icons/md' +import LibraryList from './LibraryList' +import LibraryEdit from './LibraryEdit' +import LibraryCreate from './LibraryCreate' + +export default { + icon: MdLibraryMusic, + list: LibraryList, + edit: LibraryEdit, + create: LibraryCreate, +} diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 74711eed0..87d9f629f 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -5,10 +5,15 @@ import { TextField, downloadCSV, Pagination, + Filter, + ReferenceInput, + useTranslate, + SelectInput, } from 'react-admin' import jsonExport from 'jsonexport/dist' import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' import MissingListActions from './MissingListActions.jsx' +import React from 'react' const exporter = (files) => { const filesToExport = files.map((file) => { @@ -20,6 +25,24 @@ const exporter = (files) => { }) } +const MissingFilesFilter = (props) => { + const translate = useTranslate() + return ( + <Filter {...props} variant={'outlined'}> + <ReferenceInput + label={translate('resources.missing.fields.libraryName')} + source="library_id" + reference="library" + sort={{ field: 'name', order: 'ASC' }} + filterToQuery={(searchText) => ({ name: [searchText] })} + alwaysOn + > + <SelectInput emptyText="-- All --" optionText="name" /> + </ReferenceInput> + </Filter> + ) +} + const BulkActionButtons = (props) => ( <> <DeleteMissingFilesButton {...props} /> @@ -38,11 +61,13 @@ const MissingFilesList = (props) => { sort={{ field: 'updated_at', order: 'DESC' }} exporter={exporter} actions={<MissingListActions />} + filters={<MissingFilesFilter />} bulkActionButtons={<BulkActionButtons />} perPage={50} pagination={<MissingPagination />} > <Datagrid> + <TextField source={'libraryName'} /> <TextField source={'path'} /> <SizeField source={'size'} /> <DateField source={'updatedAt'} showTime /> diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index b9414c864..3db0b1dff 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -1,3 +1,4 @@ +export * from './libraryReducer' export * from './themeReducer' export * from './dialogReducer' export * from './playerReducer' diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js new file mode 100644 index 000000000..7cda10bcf --- /dev/null +++ b/ui/src/reducers/libraryReducer.js @@ -0,0 +1,31 @@ +import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions' + +const initialState = { + userLibraries: [], + selectedLibraries: [], // Empty means "all accessible libraries" +} + +export const libraryReducer = (previousState = initialState, payload) => { + const { type, data } = payload + switch (type) { + case SET_USER_LIBRARIES: + return { + ...previousState, + userLibraries: data, + // If this is the first time setting user libraries and no selection exists, + // default to all libraries + selectedLibraries: + previousState.selectedLibraries.length === 0 && + previousState.userLibraries.length === 0 + ? data.map((lib) => lib.id) + : previousState.selectedLibraries, + } + case SET_SELECTED_LIBRARIES: + return { + ...previousState, + selectedLibraries: data, + } + default: + return previousState + } +} diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js index e4877eb14..4888e49e4 100644 --- a/ui/src/store/createAdminStore.js +++ b/ui/src/store/createAdminStore.js @@ -57,6 +57,7 @@ const createAdminStore = ({ const state = store.getState() saveState({ theme: state.theme, + library: state.library, player: (({ queue, volume, savedPlayIndex }) => ({ queue, volume, diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx new file mode 100644 index 000000000..4967720cd --- /dev/null +++ b/ui/src/user/LibrarySelectionField.jsx @@ -0,0 +1,55 @@ +import { useInput, useTranslate, useRecordContext } from 'react-admin' +import { Box, FormControl, FormLabel, Typography } from '@material-ui/core' +import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx' +import React, { useMemo } from 'react' + +export const LibrarySelectionField = () => { + const translate = useTranslate() + const record = useRecordContext() + + const { + input: { name, onChange, value }, + meta: { error, touched }, + } = useInput({ source: 'libraryIds' }) + + // Extract library IDs from either 'libraries' array or 'libraryIds' array + const libraryIds = useMemo(() => { + // First check if form has libraryIds (create mode or already transformed) + if (value && Array.isArray(value)) { + return value + } + + // Then check if record has libraries array (edit mode from backend) + if (record?.libraries && Array.isArray(record.libraries)) { + return record.libraries.map((lib) => lib.id) + } + + return [] + }, [value, record]) + + // Determine if this is a new user (no ID means new record) + const isNewUser = !record?.id + + return ( + <FormControl error={!!(touched && error)} fullWidth margin="normal"> + <FormLabel component="legend"> + {translate('resources.user.fields.libraries')} + </FormLabel> + <Box mt={1} mb={1}> + <SelectLibraryInput + onChange={onChange} + value={libraryIds} + isNewUser={isNewUser} + /> + </Box> + {touched && error && ( + <Typography color="error" variant="caption"> + {error} + </Typography> + )} + <Typography variant="caption" color="textSecondary"> + {translate('resources.user.helperTexts.libraries')} + </Typography> + </FormControl> + ) +} diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx new file mode 100644 index 000000000..9777bab99 --- /dev/null +++ b/ui/src/user/LibrarySelectionField.test.jsx @@ -0,0 +1,168 @@ +import * as React from 'react' +import { render, screen, cleanup } from '@testing-library/react' +import { LibrarySelectionField } from './LibrarySelectionField' +import { useInput, useTranslate, useRecordContext } from 'react-admin' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { SelectLibraryInput } from '../common/SelectLibraryInput' + +// Mock the react-admin hooks +vi.mock('react-admin', () => ({ + useInput: vi.fn(), + useTranslate: vi.fn(), + useRecordContext: vi.fn(), +})) + +// Mock the SelectLibraryInput component +vi.mock('../common/SelectLibraryInput.jsx', () => ({ + SelectLibraryInput: vi.fn(() => <div data-testid="select-library-input" />), +})) + +describe('<LibrarySelectionField />', () => { + const defaultProps = { + input: { + name: 'libraryIds', + value: [], + onChange: vi.fn(), + }, + meta: { + touched: false, + error: undefined, + }, + } + + const mockTranslate = vi.fn((key) => key) + + beforeEach(() => { + useInput.mockReturnValue(defaultProps) + useTranslate.mockReturnValue(mockTranslate) + useRecordContext.mockReturnValue({}) + SelectLibraryInput.mockClear() + }) + + afterEach(cleanup) + + it('should render field label from translations', () => { + render(<LibrarySelectionField />) + expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull() + }) + + it('should render helper text from translations', () => { + render(<LibrarySelectionField />) + expect( + screen.getByText('resources.user.helperTexts.libraries'), + ).not.toBeNull() + }) + + it('should render SelectLibraryInput with correct props', () => { + render(<LibrarySelectionField />) + expect(screen.getByTestId('select-library-input')).not.toBeNull() + expect(SelectLibraryInput).toHaveBeenCalledWith( + expect.objectContaining({ + onChange: defaultProps.input.onChange, + value: defaultProps.input.value, + }), + expect.anything(), + ) + }) + + it('should render error message when touched and has error', () => { + useInput.mockReturnValue({ + ...defaultProps, + meta: { + touched: true, + error: 'This field is required', + }, + }) + + render(<LibrarySelectionField />) + expect(screen.getByText('This field is required')).not.toBeNull() + }) + + it('should not render error message when not touched', () => { + useInput.mockReturnValue({ + ...defaultProps, + meta: { + touched: false, + error: 'This field is required', + }, + }) + + render(<LibrarySelectionField />) + expect(screen.queryByText('This field is required')).toBeNull() + }) + + it('should initialize with empty array when value is null', () => { + useInput.mockReturnValue({ + ...defaultProps, + input: { + ...defaultProps.input, + value: null, + }, + }) + + render(<LibrarySelectionField />) + expect(SelectLibraryInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: [], + }), + expect.anything(), + ) + }) + + it('should extract library IDs from record libraries array when editing user', () => { + // Mock a record with libraries array (from backend during edit) + useRecordContext.mockReturnValue({ + id: 'user123', + name: 'John Doe', + libraries: [ + { id: 1, name: 'Music Library 1', path: '/music1' }, + { id: 3, name: 'Music Library 3', path: '/music3' }, + ], + }) + + // Mock input without libraryIds (edit mode scenario) + useInput.mockReturnValue({ + ...defaultProps, + input: { + ...defaultProps.input, + value: undefined, + }, + }) + + render(<LibrarySelectionField />) + expect(SelectLibraryInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: [1, 3], // Should extract IDs from libraries array + }), + expect.anything(), + ) + }) + + it('should prefer libraryIds when both libraryIds and libraries are present', () => { + // Mock a record with libraries array + useRecordContext.mockReturnValue({ + id: 'user123', + libraries: [ + { id: 1, name: 'Music Library 1', path: '/music1' }, + { id: 3, name: 'Music Library 3', path: '/music3' }, + ], + }) + + // Mock input with explicit libraryIds (create mode or already transformed) + useInput.mockReturnValue({ + ...defaultProps, + input: { + ...defaultProps.input, + value: [2, 4], // Different IDs than in libraries + }, + }) + + render(<LibrarySelectionField />) + expect(SelectLibraryInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: [2, 4], // Should prefer libraryIds over libraries + }), + expect.anything(), + ) + }) +}) diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.jsx index 42ea1ce94..ce69b6542 100644 --- a/ui/src/user/UserCreate.jsx +++ b/ui/src/user/UserCreate.jsx @@ -2,17 +2,20 @@ import React, { useCallback } from 'react' import { BooleanInput, Create, - TextInput, + email, + FormDataConsumer, PasswordInput, required, - email, SimpleForm, - useTranslate, + TextInput, useMutation, useNotify, useRedirect, + useTranslate, } from 'react-admin' +import { Typography } from '@material-ui/core' import { Title } from '../common' +import { LibrarySelectionField } from './LibrarySelectionField.jsx' const UserCreate = (props) => { const translate = useTranslate() @@ -48,9 +51,17 @@ const UserCreate = (props) => { [mutate, notify, redirect], ) + // Custom validation function + const validateUserForm = (values) => { + const errors = {} + // Library selection is optional for non-admin users since they will be auto-assigned to default libraries + // No validation required for library selection + return errors + } + return ( <Create title={<Title subTitle={title} />} {...props}> - <SimpleForm save={save} variant={'outlined'}> + <SimpleForm save={save} validate={validateUserForm} variant={'outlined'}> <TextInput spellCheck={false} source="userName" @@ -64,6 +75,25 @@ const UserCreate = (props) => { validate={[required()]} /> <BooleanInput source="isAdmin" defaultValue={false} /> + + {/* Conditional Library Selection */} + <FormDataConsumer> + {({ formData }) => ( + <> + {!formData.isAdmin && <LibrarySelectionField />} + + {formData.isAdmin && ( + <Typography + variant="body2" + color="textSecondary" + style={{ marginTop: 16, marginBottom: 16 }} + > + {translate('resources.user.message.adminAutoLibraries')} + </Typography> + )} + </> + )} + </FormDataConsumer> </SimpleForm> </Create> ) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index 445f9c6fd..2283dd8bc 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -18,9 +18,13 @@ import { useRefresh, FormDataConsumer, usePermissions, + useRecordContext, } from 'react-admin' +import { Typography } from '@material-ui/core' import { Title } from '../common' import DeleteUserButton from './DeleteUserButton' +import { LibrarySelectionField } from './LibrarySelectionField.jsx' +import { validateUserForm } from './userValidation' const useStyles = makeStyles({ toolbar: { @@ -100,12 +104,18 @@ const UserEdit = (props) => { [mutate, notify, permissions, redirect, refresh], ) + // Custom validation function + const validateForm = (values) => { + return validateUserForm(values, translate) + } + return ( <Edit title={<UserTitle />} undoable={false} {...props}> <SimpleForm variant={'outlined'} toolbar={<UserToolbar showDelete={canDelete} />} save={save} + validate={validateForm} > {permissions === 'admin' && ( <TextInput @@ -139,6 +149,28 @@ const UserEdit = (props) => { {permissions === 'admin' && ( <BooleanInput source="isAdmin" initialValue={false} /> )} + + {/* Conditional Library Selection for Admin Users Only */} + {permissions === 'admin' && ( + <FormDataConsumer> + {({ formData }) => ( + <> + {!formData.isAdmin && <LibrarySelectionField />} + + {formData.isAdmin && ( + <Typography + variant="body2" + color="textSecondary" + style={{ marginTop: 16, marginBottom: 16 }} + > + {translate('resources.user.message.adminAutoLibraries')} + </Typography> + )} + </> + )} + </FormDataConsumer> + )} + <DateField variant="body1" source="lastLoginAt" showTime /> <DateField variant="body1" source="lastAccessAt" showTime /> <DateField variant="body1" source="updatedAt" showTime /> diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx new file mode 100644 index 000000000..75a9a1ada --- /dev/null +++ b/ui/src/user/UserEdit.test.jsx @@ -0,0 +1,130 @@ +import * as React from 'react' +import { render, screen } from '@testing-library/react' +import UserEdit from './UserEdit' +import { describe, it, expect, vi } from 'vitest' + +const defaultUser = { + id: 'user1', + userName: 'testuser', + name: 'Test User', + email: 'test@example.com', + isAdmin: false, + libraries: [ + { id: 1, name: 'Library 1', path: '/music1' }, + { id: 2, name: 'Library 2', path: '/music2' }, + ], + lastLoginAt: '2023-01-01T12:00:00Z', + lastAccessAt: '2023-01-02T12:00:00Z', + updatedAt: '2023-01-03T12:00:00Z', + createdAt: '2023-01-04T12:00:00Z', +} + +const adminUser = { + ...defaultUser, + id: 'admin1', + userName: 'admin', + name: 'Admin User', + isAdmin: true, +} + +// Mock React-Admin completely with simpler implementations +vi.mock('react-admin', () => ({ + Edit: ({ children, title }) => ( + <div data-testid="edit-component"> + {title} + {children} + </div> + ), + SimpleForm: ({ children }) => ( + <form data-testid="simple-form">{children}</form> + ), + TextInput: ({ source }) => <input data-testid={`text-input-${source}`} />, + BooleanInput: ({ source }) => ( + <input type="checkbox" data-testid={`boolean-input-${source}`} /> + ), + DateField: ({ source }) => ( + <div data-testid={`date-field-${source}`}>Date</div> + ), + PasswordInput: ({ source }) => ( + <input type="password" data-testid={`password-input-${source}`} /> + ), + Toolbar: ({ children }) => <div data-testid="toolbar">{children}</div>, + SaveButton: () => <button data-testid="save-button">Save</button>, + FormDataConsumer: ({ children }) => children({ formData: {} }), + Typography: ({ children }) => <p>{children}</p>, + required: () => () => null, + email: () => () => null, + useMutation: () => [vi.fn()], + useNotify: () => vi.fn(), + useRedirect: () => vi.fn(), + useRefresh: () => vi.fn(), + usePermissions: () => ({ permissions: 'admin' }), + useTranslate: () => (key) => key, +})) + +vi.mock('./LibrarySelectionField.jsx', () => ({ + LibrarySelectionField: () => <div data-testid="library-selection-field" />, +})) + +vi.mock('./DeleteUserButton', () => ({ + __esModule: true, + default: () => <button data-testid="delete-user-button">Delete</button>, +})) + +vi.mock('../common', () => ({ + Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>, +})) + +// Mock Material-UI +vi.mock('@material-ui/core/styles', () => ({ + makeStyles: () => () => ({}), +})) + +vi.mock('@material-ui/core', () => ({ + Typography: ({ children }) => <p>{children}</p>, +})) + +describe('<UserEdit />', () => { + it('should render the user edit form', () => { + render(<UserEdit id="user1" permissions="admin" />) + + // Check if the edit component renders + expect(screen.getByTestId('edit-component')).toBeInTheDocument() + expect(screen.getByTestId('simple-form')).toBeInTheDocument() + }) + + it('should render text inputs for admin users', () => { + render(<UserEdit id="user1" permissions="admin" />) + + // Should render username input for admin + expect(screen.getByTestId('text-input-userName')).toBeInTheDocument() + expect(screen.getByTestId('text-input-name')).toBeInTheDocument() + expect(screen.getByTestId('text-input-email')).toBeInTheDocument() + }) + + it('should render admin checkbox for admin permissions', () => { + render(<UserEdit id="user1" permissions="admin" />) + + // Should render isAdmin checkbox for admin users + expect(screen.getByTestId('boolean-input-isAdmin')).toBeInTheDocument() + }) + + it('should render date fields', () => { + render(<UserEdit id="user1" permissions="admin" />) + + expect(screen.getByTestId('date-field-lastLoginAt')).toBeInTheDocument() + expect(screen.getByTestId('date-field-lastAccessAt')).toBeInTheDocument() + expect(screen.getByTestId('date-field-updatedAt')).toBeInTheDocument() + expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument() + }) + + it('should not render username input for non-admin users', () => { + render(<UserEdit id="user1" permissions="user" />) + + // Should not render username input for non-admin + expect(screen.queryByTestId('text-input-userName')).not.toBeInTheDocument() + // But should still render name and email + expect(screen.getByTestId('text-input-name')).toBeInTheDocument() + expect(screen.getByTestId('text-input-email')).toBeInTheDocument() + }) +}) diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js new file mode 100644 index 000000000..e90fd2acb --- /dev/null +++ b/ui/src/user/userValidation.js @@ -0,0 +1,19 @@ +// User form validation utilities +export const validateUserForm = (values, translate) => { + const errors = {} + + // Only require library selection for non-admin users + if (!values.isAdmin) { + // Check both libraryIds (array of IDs) and libraries (array of objects) + const hasLibraryIds = values.libraryIds && values.libraryIds.length > 0 + const hasLibraries = values.libraries && values.libraries.length > 0 + + if (!hasLibraryIds && !hasLibraries) { + errors.libraryIds = translate( + 'resources.user.validation.librariesRequired', + ) + } + } + + return errors +} diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js new file mode 100644 index 000000000..2ee473910 --- /dev/null +++ b/ui/src/user/userValidation.test.js @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest' +import { validateUserForm } from './userValidation' + +describe('User Validation Utilities', () => { + const mockTranslate = vi.fn((key) => key) + + describe('validateUserForm', () => { + it('should not return errors for admin users', () => { + const values = { + isAdmin: true, + libraryIds: [], + } + const errors = validateUserForm(values, mockTranslate) + expect(errors).toEqual({}) + }) + + it('should not return errors for non-admin users with libraries', () => { + const values = { + isAdmin: false, + libraryIds: [1, 2, 3], + } + const errors = validateUserForm(values, mockTranslate) + expect(errors).toEqual({}) + }) + + it('should return error for non-admin users without libraries', () => { + const values = { + isAdmin: false, + libraryIds: [], + } + const errors = validateUserForm(values, mockTranslate) + expect(errors.libraryIds).toBe( + 'resources.user.validation.librariesRequired', + ) + }) + + it('should return error for non-admin users with undefined libraryIds', () => { + const values = { + isAdmin: false, + } + const errors = validateUserForm(values, mockTranslate) + expect(errors.libraryIds).toBe( + 'resources.user.validation.librariesRequired', + ) + }) + + it('should not return errors for non-admin users with libraries array', () => { + const values = { + isAdmin: false, + libraries: [ + { id: 1, name: 'Library 1' }, + { id: 2, name: 'Library 2' }, + ], + } + const errors = validateUserForm(values, mockTranslate) + expect(errors).toEqual({}) + }) + + it('should return error for non-admin users with empty libraries array', () => { + const values = { + isAdmin: false, + libraries: [], + } + const errors = validateUserForm(values, mockTranslate) + expect(errors.libraryIds).toBe( + 'resources.user.validation.librariesRequired', + ) + }) + }) +}) diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index ae27f230f..74cce6e15 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -25,6 +25,42 @@ export const formatDuration = (d) => { return `${days > 0 ? days + ':' : ''}${f}` } +export const formatDuration2 = (totalSeconds) => { + if (totalSeconds == null || totalSeconds < 0) { + return '0s' + } + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = Math.floor(totalSeconds % 60) + + const parts = [] + + if (days > 0) { + // When days are present, show only d h m (3 levels max) + parts.push(`${days}d`) + if (hours > 0) { + parts.push(`${hours}h`) + } + if (minutes > 0) { + parts.push(`${minutes}m`) + } + } else { + // When no days, show h m s (3 levels max) + if (hours > 0) { + parts.push(`${hours}h`) + } + if (minutes > 0) { + parts.push(`${minutes}m`) + } + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`) + } + } + + return parts.join(' ') +} + export const formatShortDuration = (ns) => { // Convert nanoseconds to seconds const seconds = ns / 1e9 @@ -58,3 +94,8 @@ export const formatFullDate = (date, locale) => { } return new Date(date).toLocaleDateString(locale, options) } + +export const formatNumber = (value) => { + if (value === null || value === undefined) return '0' + return value.toLocaleString() +} diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index 87b40f16b..7709dd91b 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -1,7 +1,9 @@ import { formatBytes, formatDuration, + formatDuration2, formatFullDate, + formatNumber, formatShortDuration, } from './formatters' @@ -64,6 +66,85 @@ describe('formatShortDuration', () => { }) }) +describe('formatDuration2', () => { + it('handles null and undefined values', () => { + expect(formatDuration2(null)).toEqual('0s') + expect(formatDuration2(undefined)).toEqual('0s') + }) + + it('handles negative values', () => { + expect(formatDuration2(-10)).toEqual('0s') + expect(formatDuration2(-1)).toEqual('0s') + }) + + it('formats zero seconds', () => { + expect(formatDuration2(0)).toEqual('0s') + }) + + it('formats seconds only', () => { + expect(formatDuration2(1)).toEqual('1s') + expect(formatDuration2(30)).toEqual('30s') + expect(formatDuration2(59)).toEqual('59s') + }) + + it('formats minutes and seconds', () => { + expect(formatDuration2(60)).toEqual('1m') + expect(formatDuration2(90)).toEqual('1m 30s') + expect(formatDuration2(119)).toEqual('1m 59s') + expect(formatDuration2(120)).toEqual('2m') + }) + + it('formats hours, minutes and seconds', () => { + expect(formatDuration2(3600)).toEqual('1h') + expect(formatDuration2(3661)).toEqual('1h 1m 1s') + expect(formatDuration2(7200)).toEqual('2h') + expect(formatDuration2(7260)).toEqual('2h 1m') + expect(formatDuration2(7261)).toEqual('2h 1m 1s') + }) + + it('handles decimal values by flooring', () => { + expect(formatDuration2(59.9)).toEqual('59s') + expect(formatDuration2(60.1)).toEqual('1m') + expect(formatDuration2(3600.9)).toEqual('1h') + }) + + it('formats days with maximum 3 levels (d h m)', () => { + expect(formatDuration2(86400)).toEqual('1d') + expect(formatDuration2(86461)).toEqual('1d 1m') // seconds dropped when days present + expect(formatDuration2(90061)).toEqual('1d 1h 1m') // seconds dropped when days present + expect(formatDuration2(172800)).toEqual('2d') + expect(formatDuration2(176400)).toEqual('2d 1h') + expect(formatDuration2(176460)).toEqual('2d 1h 1m') + expect(formatDuration2(176461)).toEqual('2d 1h 1m') // seconds dropped when days present + }) +}) + +describe('formatNumber', () => { + it('handles null and undefined values', () => { + expect(formatNumber(null)).toEqual('0') + expect(formatNumber(undefined)).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') + }) + + it('formats decimal numbers', () => { + expect(formatNumber(123.45)).toEqual('123.45') + expect(formatNumber(1234.567)).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') + }) +}) + describe('formatFullDate', () => { it('format dates', () => { expect(formatFullDate('2011', 'en-US')).toEqual('2011') diff --git a/utils/files_test.go b/utils/files_test.go new file mode 100644 index 000000000..dcb28aafb --- /dev/null +++ b/utils/files_test.go @@ -0,0 +1,178 @@ +package utils_test + +import ( + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TempFileName", func() { + It("creates a temporary file name with prefix and suffix", func() { + prefix := "test-" + suffix := ".tmp" + result := utils.TempFileName(prefix, suffix) + + Expect(result).To(ContainSubstring(prefix)) + Expect(result).To(HaveSuffix(suffix)) + Expect(result).To(ContainSubstring(os.TempDir())) + }) + + It("creates unique file names on multiple calls", func() { + prefix := "unique-" + suffix := ".test" + + result1 := utils.TempFileName(prefix, suffix) + result2 := utils.TempFileName(prefix, suffix) + + Expect(result1).NotTo(Equal(result2)) + }) + + It("handles empty prefix and suffix", func() { + result := utils.TempFileName("", "") + + Expect(result).To(ContainSubstring(os.TempDir())) + Expect(len(result)).To(BeNumerically(">", len(os.TempDir()))) + }) + + It("creates proper file path separators", func() { + prefix := "path-test-" + suffix := ".ext" + result := utils.TempFileName(prefix, suffix) + + expectedDir := os.TempDir() + Expect(result).To(HavePrefix(expectedDir)) + Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator)))) + }) +}) + +var _ = Describe("BaseName", func() { + It("extracts basename from a simple filename", func() { + result := utils.BaseName("test.mp3") + Expect(result).To(Equal("test")) + }) + + It("extracts basename from a file path", func() { + result := utils.BaseName("/path/to/file.txt") + Expect(result).To(Equal("file")) + }) + + It("handles files without extension", func() { + result := utils.BaseName("/path/to/filename") + Expect(result).To(Equal("filename")) + }) + + It("handles files with multiple dots", func() { + result := utils.BaseName("archive.tar.gz") + Expect(result).To(Equal("archive.tar")) + }) + + It("handles hidden files", func() { + // For hidden files without additional extension, path.Ext returns the entire name + // So basename becomes empty string after TrimSuffix + result := utils.BaseName(".hidden") + Expect(result).To(Equal("")) + }) + + It("handles hidden files with extension", func() { + result := utils.BaseName(".config.json") + Expect(result).To(Equal(".config")) + }) + + It("handles empty string", func() { + // The actual behavior returns empty string for empty input + result := utils.BaseName("") + Expect(result).To(Equal("")) + }) + + It("handles path ending with separator", func() { + result := utils.BaseName("/path/to/dir/") + Expect(result).To(Equal("dir")) + }) + + It("handles complex nested path", func() { + result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3") + Expect(result).To(Equal("song")) + }) +}) + +var _ = Describe("FileExists", func() { + var tempFile *os.File + var tempDir string + + BeforeEach(func() { + var err error + tempFile, err = os.CreateTemp("", "fileexists-test-*.txt") + Expect(err).NotTo(HaveOccurred()) + + tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + if tempFile != nil { + os.Remove(tempFile.Name()) + tempFile.Close() + } + if tempDir != "" { + os.RemoveAll(tempDir) + } + }) + + It("returns true for existing file", func() { + Expect(utils.FileExists(tempFile.Name())).To(BeTrue()) + }) + + It("returns true for existing directory", func() { + Expect(utils.FileExists(tempDir)).To(BeTrue()) + }) + + It("returns false for non-existing file", func() { + nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt") + Expect(utils.FileExists(nonExistentPath)).To(BeFalse()) + }) + + It("returns false for empty path", func() { + Expect(utils.FileExists("")).To(BeFalse()) + }) + + It("handles nested non-existing path", func() { + nonExistentPath := "/this/path/definitely/does/not/exist/file.txt" + Expect(utils.FileExists(nonExistentPath)).To(BeFalse()) + }) + + Context("when file is deleted after creation", func() { + It("returns false after file deletion", func() { + filePath := tempFile.Name() + Expect(utils.FileExists(filePath)).To(BeTrue()) + + err := os.Remove(filePath) + Expect(err).NotTo(HaveOccurred()) + tempFile = nil // Prevent cleanup attempt + + Expect(utils.FileExists(filePath)).To(BeFalse()) + }) + }) + + Context("when directory is deleted after creation", func() { + It("returns false after directory deletion", func() { + dirPath := tempDir + Expect(utils.FileExists(dirPath)).To(BeTrue()) + + err := os.RemoveAll(dirPath) + Expect(err).NotTo(HaveOccurred()) + tempDir = "" // Prevent cleanup attempt + + Expect(utils.FileExists(dirPath)).To(BeFalse()) + }) + }) + + It("handles permission denied scenarios gracefully", func() { + // This test might be platform specific, but we test the general case + result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible + Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic + }) +}) From a569f6788e2c668732720b6c729fe776fb428062 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 18 Jul 2025 18:59:52 -0400 Subject: [PATCH 10/64] fix(ui): update Portuguese translation and remove unused terms Signed-off-by: Deluan <deluan@navidrome.org> --- resources/i18n/pt-br.json | 76 ++++++++++++++++++++++++++++++++++++--- ui/src/i18n/en.json | 7 +--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 454de39db..9c22d509f 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -12,6 +12,7 @@ "artist": "Artista", "album": "Álbum", "path": "Arquivo", + "libraryName": "Biblioteca", "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", @@ -57,6 +58,7 @@ "songCount": "Músicas", "playCount": "Execuções", "name": "Nome", + "libraryName": "Biblioteca", "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", @@ -147,19 +149,26 @@ "currentPassword": "Senha Atual", "newPassword": "Nova Senha", "token": "Token", - "lastAccessAt": "Últ. Acesso" + "lastAccessAt": "Últ. Acesso", + "libraries": "Bibliotecas" }, "helperTexts": { - "name": "Alterações no seu nome só serão refletidas no próximo login" + "name": "Alterações no seu nome só serão refletidas no próximo login", + "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão" }, "notifications": { "created": "Novo usuário criado", "updated": "Usuário atualizado com sucesso", "deleted": "Usuário deletado com sucesso" }, + "validation": { + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" + }, "message": { "listenBrainzToken": "Entre seu token do ListenBrainz", - "clickHereForToken": "Clique aqui para obter seu token" + "clickHereForToken": "Clique aqui para obter seu token", + "selectAllLibraries": "Selecionar todas as bibliotecas", + "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" } }, "player": { @@ -251,6 +260,7 @@ "fields": { "path": "Caminho", "size": "Tamanho", + "libraryName": "Biblioteca", "updatedAt": "Desaparecido em" }, "actions": { @@ -261,6 +271,58 @@ "removed": "Arquivo(s) ausente(s) removido(s)" }, "empty": "Nenhum arquivo ausente" + }, + "library": { + "name": "Biblioteca |||| Bibliotecas", + "fields": { + "name": "Nome", + "path": "Caminho", + "remotePath": "Caminho Remoto", + "lastScanAt": "Último Scan", + "songCount": "Músicas", + "albumCount": "Álbuns", + "artistCount": "Artistas", + "totalSongs": "Músicas", + "totalAlbums": "Álbuns", + "totalArtists": "Artistas", + "totalFolders": "Pastas", + "totalFiles": "Arquivos", + "totalMissingFiles": "Arquivos Ausentes", + "totalSize": "Tamanho Total", + "totalDuration": "Duração", + "defaultNewUsers": "Padrão para Novos Usuários", + "createdAt": "Data de Criação", + "updatedAt": "Últ. Atualização" + }, + "sections": { + "basic": "Informações Básicas", + "statistics": "Estatísticas" + }, + "actions": { + "scan": "Scanear Biblioteca", + "manageUsers": "Gerenciar Acesso do Usuário", + "viewDetails": "Ver Detalhes" + }, + "notifications": { + "created": "Biblioteca criada com sucesso", + "updated": "Biblioteca atualizada com sucesso", + "deleted": "Biblioteca excluída com sucesso", + "scanStarted": "Scan da biblioteca iniciada", + "scanCompleted": "Scan da biblioteca concluída" + }, + "validation": { + "nameRequired": "Nome da biblioteca é obrigatório", + "pathRequired": "Caminho da biblioteca é obrigatório", + "pathNotDirectory": "Caminho da biblioteca deve ser um diretório", + "pathNotFound": "Caminho da biblioteca não encontrado", + "pathNotAccessible": "Caminho da biblioteca não está acessível", + "pathInvalid": "Caminho da biblioteca inválido" + }, + "messages": { + "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", + "scanInProgress": "Scan em progresso...", + "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário" + } } }, "ra": { @@ -448,6 +510,12 @@ }, "menu": { "library": "Biblioteca", + "librarySelector": { + "allLibraries": "Todas as Bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} Bibliotecas", + "selectLibraries": "Selecionar Bibliotecas", + "none": "Nenhuma" + }, "settings": "Configurações", "version": "Versão", "theme": "Tema", @@ -529,7 +597,7 @@ }, "activity": { "title": "Atividade", - "totalScanned": "Total de pastas analisadas", + "totalScanned": "Total de pastas scaneadas", "quickScan": "Scan rápido", "fullScan": "Scan completo", "serverUptime": "Uptime do servidor", diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index f384df2d2..4a9039a67 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -284,10 +284,6 @@ "songCount": "Songs", "albumCount": "Albums", "artistCount": "Artists", - "scanCount": "Scan Count", - "missingFileCount": "Missing Files", - "size": "Size", - "duration": "Duration", "totalSongs": "Songs", "totalAlbums": "Albums", "totalArtists": "Artists", @@ -302,8 +298,7 @@ }, "sections": { "basic": "Basic Information", - "statistics": "Statistics", - "scan": "Scan Information" + "statistics": "Statistics" }, "actions": { "scan": "Scan Library", From a60bea70c90be25ee243c22d78d7c16ae71b3046 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 18 Jul 2025 21:43:52 -0400 Subject: [PATCH 11/64] fix(ui): replace NumberInput with TextInput for read-only fields in LibraryEdit Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/library/LibraryEdit.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index f00fbf7c6..3d981b076 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -11,7 +11,6 @@ import { useMutation, useNotify, useRedirect, - NumberInput, Toolbar, } from 'react-admin' import { Typography, Box } from '@material-ui/core' @@ -128,7 +127,7 @@ const LibraryEdit = (props) => { <Box display="flex"> <Box flex={1} mr="0.5em"> - <NumberInput + <TextInput InputProps={{ readOnly: true }} resource={'library'} source={'totalSongs'} @@ -138,7 +137,7 @@ const LibraryEdit = (props) => { /> </Box> <Box flex={1} ml="0.5em"> - <NumberInput + <TextInput InputProps={{ readOnly: true }} resource={'library'} source={'totalAlbums'} @@ -153,7 +152,7 @@ const LibraryEdit = (props) => { <Box display="flex"> <Box flex={1} mr="0.5em"> - <NumberInput + <TextInput InputProps={{ readOnly: true }} resource={'library'} source={'totalArtists'} From 9f46204b63d6577f9c221cd6cc58494b61c29fba Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 19 Jul 2025 16:44:07 -0400 Subject: [PATCH 12/64] fix(subsonic): artist search in search3 endpoint Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/searching.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index d8f85afeb..e617535ad 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -68,17 +68,25 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI // Create query options for library filtering var options []model.QueryOptions + var artistOptions []model.QueryOptions if len(musicFolderIds) > 0 { + // For MediaFiles and Albums, use direct library_id filter options = append(options, model.QueryOptions{ Filters: Eq{"library_id": musicFolderIds}, }) + // For Artists, use the repository's built-in library filtering mechanism + // which properly handles the library_artist table joins + // TODO Revisit library filtering in sql_base_repository.go + artistOptions = append(artistOptions, model.QueryOptions{ + Filters: Eq{"library_artist.library_id": musicFolderIds}, + }) } // Run searches in parallel g, ctx := errgroup.WithContext(ctx) g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...)) g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...)) - g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, options...)) + g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...)) err := g.Wait() if err == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", From d5fa46e948515d40f84e185891a854a3da29747e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 20 Jul 2025 01:52:29 +0000 Subject: [PATCH 13/64] fix(subsonic): only use genre tag when searching by genre (#4361) --- server/subsonic/filter/filters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index a0bce9041..8ba4f0ff9 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -163,7 +163,7 @@ func ByGenre(genre string) Options { } func filterByGenre(genre string) Sqlizer { - return persistence.Exists("json_tree(tags)", And{ + return persistence.Exists(`json_tree(tags, "$.genre")`, And{ Like{"value": genre}, NotEq{"atom": nil}, }) From 9fcc996336f96092407981d791bdc62a210327f8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sun, 20 Jul 2025 10:43:04 -0400 Subject: [PATCH 14/64] fix(plugins): prevent race condition in plugin tests Add EnsureCompiled calls in plugin test BeforeEach blocks to wait for WebAssembly compilation before loading plugins. This prevents race conditions where tests would attempt to load plugins before compilation completed, causing flaky test failures in CI environments. The race condition occurred because ScanPlugins() registers plugins synchronously but compiles them asynchronously in background goroutines with a concurrency limit of 2. Tests that immediately called LoadPlugin() or LoadMediaAgent() after ScanPlugins() could fail if compilation wasn't finished yet. Fixed in both adapter_media_agent_test.go and manager_test.go which had multiple tests vulnerable to this timing issue. --- plugins/adapter_media_agent_test.go | 6 ++++++ plugins/manager_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go index f8b61ea5f..70b5d275a 100644 --- a/plugins/adapter_media_agent_test.go +++ b/plugins/adapter_media_agent_test.go @@ -26,6 +26,12 @@ var _ = Describe("Adapter Media Agent", func() { mgr = createManager(nil, metrics.NewNoopInstance()) mgr.ScanPlugins() + + // Wait for all plugins to compile to avoid race conditions + err := mgr.EnsureCompiled("multi_plugin") + Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully") + err = mgr.EnsureCompiled("fake_album_agent") + Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully") }) Describe("AgentName and PluginName", func() { diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 9445979c2..2a6ad575f 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -31,6 +31,16 @@ var _ = Describe("Plugin Manager", func() { ctx = GinkgoT().Context() mgr = createManager(nil, metrics.NewNoopInstance()) mgr.ScanPlugins() + + // Wait for all plugins to compile to avoid race conditions + err := mgr.EnsureCompiled("fake_artist_agent") + Expect(err).NotTo(HaveOccurred(), "fake_artist_agent should compile successfully") + err = mgr.EnsureCompiled("fake_album_agent") + Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully") + err = mgr.EnsureCompiled("multi_plugin") + Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully") + err = mgr.EnsureCompiled("unauthorized_plugin") + Expect(err).NotTo(HaveOccurred(), "unauthorized_plugin should compile successfully") }) It("should scan and discover plugins from the testdata folder", func() { From 72031d99ed78acea0fbd502902d129f8c82d0340 Mon Sep 17 00:00:00 2001 From: emmmm <155267286+eeemmmmmm@users.noreply.github.com> Date: Sun, 20 Jul 2025 13:36:46 -0400 Subject: [PATCH 15/64] fix: typo in Dockerfile (#4363) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3600ff6d2..ec3b6d938 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross ######################################################################################################################## -### Build xx (orignal image: tonistiigi/xx) +### Build xx (original image: tonistiigi/xx) FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build # v1.5.0 From c193bb2a09bc1951b1486a102a26fd068e75165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 20 Jul 2025 15:58:21 -0400 Subject: [PATCH 16/64] fix(server): headless library access improvements (#4362) * fix: enable library access for headless processes Fixed multi-library filtering to allow headless processes (shares, external providers) to access data by skipping library restrictions when no user context is present. Previously, the library filtering system returned empty results (WHERE 1=0) for processes without user authentication, breaking functionality like public shares and external service integrations. Key changes: - Modified applyLibraryFilter methods to skip filtering when user.ID == invalidUserId - Refactored tag repository to use helper method for library filtering logic - Fixed SQL aggregation bug in tag statistics calculation across multiple libraries - Added comprehensive test coverage for headless process scenarios - Updated genre repository to support proper column mappings for aggregated data This preserves the secure "safe by default" approach for authenticated users while restoring backward compatibility for background processes that need unrestricted data access. Signed-off-by: Deluan <deluan@navidrome.org> * fix: resolve SQL ambiguity errors in share queries Fixed SQL ambiguity errors that were breaking share links after the Multi-library PR. The Multi-library changes introduced JOINs between album and library tables, both of which have 'id' columns, causing 'ambiguous column name: id' errors when unqualified column references were used in WHERE clauses. Changes made: - Updated core/share.go to use 'album.id' instead of 'id' in contentsLabelFromAlbums - Updated persistence/share_repository.go to use 'album.id' in album share loading - Updated persistence/sql_participations.go to use 'artist.id' for consistency - Added regression tests to prevent future SQL ambiguity issues This resolves HTTP 500 errors that users experienced when accessing existing share URLs after the Multi-library feature was merged. Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve headless library access handling Added proper user context validation and reordered joins in applyLibraryFilterToArtistQuery to ensure library filtering works correctly for both authenticated and headless operations. The user_library join is now only applied when a valid user context exists, while the library_artist join is always applied to maintain proper data relationships. (+1 squashed commit) Squashed commits: [a28c6965b] fix: remove headless library access guard Removed the invalidUserId guard condition in applyLibraryFilterToArtistQuery that was preventing proper library filtering for headless operations. This fix ensures that library filtering joins are always applied consistently, allowing headless library access to work correctly with the library_artist junction table filtering. The previous guard was skipping all library filtering when no user context was present, which could cause issues with headless operations that still need to respect library boundaries through the library_artist relationship. * fix: simplify genre selection query in genre repository Signed-off-by: Deluan <deluan@navidrome.org> * fix: enhance tag library filtering tests for headless access Signed-off-by: Deluan <deluan@navidrome.org> * test: add comprehensive test coverage for headless library access Added extensive test coverage for headless library access improvements including: - Added 17 new tests across 4 test files covering headless access scenarios - artist_repository_test.go: Added headless process tests for GetAll, Count, Get operations and explicit library_id filtering functionality - genre_repository_test.go: Added library filtering tests for headless processes including GetAll, Count, ReadAll, and Read operations - sql_base_repository_test.go: Added applyLibraryFilter method tests covering admin users, regular users, and headless processes with/without custom table names - share_repository_test.go: Added headless access tests and SQL ambiguity verification for the album.id vs id fix in loadMedia function - Cleaned up test setup by replacing log.NewContext usage with GinkgoT().Context() and removing unnecessary configtest.SetupConfig() calls for better test isolation These tests ensure that headless processes (background operations without user context) can access all libraries while respecting explicit filters, and verify that the SQL ambiguity fixes work correctly without breaking existing functionality. * revert: remove user context handling from scrobble buffer getParticipants Reverts commit 5b8ef74f05109ecf30ddfc936361b84314522869. The artist repository no longer requires user context for proper library filtering, so the workaround of temporarily injecting user context into the scrobbleBufferRepository.Next method is no longer needed. This simplifies the code and removes the dependency on fetching user information during background scrobbling operations. * fix: improve library access filtering for artists Enhanced artist repository filtering to properly handle library access restrictions and prevent artists with no accessible content from appearing in results. Backend changes: - Modified roleFilter to use direct JSON_EXTRACT instead of EXISTS subquery for better performance - Enhanced applyLibraryFilterToArtistQuery to filter out artists with empty stats (no content) - Changed from LEFT JOIN to INNER JOIN with library_artist table for stricter filtering - Added condition to exclude artists where library_artist.stats = '{}' (empty content) Frontend changes: - Added null-checking in getCounter function to prevent TypeError when accessing undefined records - Improved optional chaining for safer property access in role-based statistics display These changes ensure that users only see artists that have actual accessible content in their permitted libraries, fixing issues where artists appeared in the list despite having no albums or songs available to the user. * fix: update library access logic for non-admin users and enhance test coverage Signed-off-by: Deluan <deluan@navidrome.org> * fix: refine library artist query and implement cleanup for empty entries Signed-off-by: Deluan <deluan@navidrome.org> * refactor: consolidate artist repository tests to eliminate duplication Significantly refactored artist_repository_test.go to reduce code duplication and improve maintainability by ~27% (930 to 680 lines). Key improvements include: - Added test helper functions createTestArtistWithMBID() and createUserWithLibraries() to eliminate repetitive test data creation - Consolidated duplicate MBID search tests using DescribeTable for parameterized testing - Removed entire 'Permission-Based Behavior Comparison' section (~150 lines) that duplicated functionality already covered in other test contexts - Reorganized search tests into cohesive 'MBID and Text Search' section with proper setup/teardown and shared test infrastructure - Streamlined missing artist tests and moved them to dedicated section - Maintained 100% test coverage while eliminating redundant test patterns All tests continue to pass with identical functionality and coverage. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/share.go | 2 +- model/tag.go | 10 +- persistence/artist_repository.go | 27 +- persistence/artist_repository_test.go | 581 ++++++++++------------ persistence/genre_repository.go | 2 +- persistence/genre_repository_test.go | 81 ++- persistence/persistence_suite_test.go | 11 +- persistence/scrobble_buffer_repository.go | 14 - persistence/share_repository.go | 2 +- persistence/share_repository_test.go | 133 +++++ persistence/sql_base_repository.go | 15 +- persistence/sql_base_repository_test.go | 58 +++ persistence/sql_participations.go | 2 +- persistence/sql_tags.go | 53 +- persistence/tag_library_filtering_test.go | 313 ++++++------ ui/src/artist/ArtistList.jsx | 6 +- 16 files changed, 770 insertions(+), 540 deletions(-) create mode 100644 persistence/share_repository_test.go diff --git a/core/share.go b/core/share.go index add88322d..202c27d89 100644 --- a/core/share.go +++ b/core/share.go @@ -149,7 +149,7 @@ func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids str func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string { idList := strings.Split(ids, ",") - all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": idList}}) + all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) if err != nil { log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) return "" diff --git a/model/tag.go b/model/tag.go index 8f9c60f37..674f688ca 100644 --- a/model/tag.go +++ b/model/tag.go @@ -12,11 +12,11 @@ import ( ) type Tag struct { - ID string `json:"id,omitempty"` - TagName TagName `json:"tagName,omitempty"` - TagValue string `json:"tagValue,omitempty"` - AlbumCount int `json:"albumCount,omitempty"` - MediaFileCount int `json:"songCount,omitempty"` + ID string `json:"id,omitempty"` + TagName TagName `json:"tagName,omitempty"` + TagValue string `json:"tagValue,omitempty"` + AlbumCount int `json:"albumCount,omitempty"` + SongCount int `json:"songCount,omitempty"` } type TagList []Tag diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index af95e0670..da46d8a11 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -156,7 +156,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { if _, ok := model.AllRoles[role]; ok { - return Expr("EXISTS (SELECT 1 FROM library_artist WHERE library_artist.artist_id = artist.id AND JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL)") + return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") } } return Eq{"1": 2} @@ -170,14 +170,16 @@ func artistLibraryIdFilter(_ string, value interface{}) Sqlizer { // applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder { user := loggedUser(r.ctx) - if user.ID == invalidUserId { - // No user context - return empty result set - return query.Where(Eq{"1": "0"}) - } + // Join with library_artist first to ensure only artists with content in libraries are included + // Exclude artists with empty stats (no actual content in the library) + query = query.Join("library_artist on library_artist.artist_id = artist.id") + //query = query.Join("library_artist on library_artist.artist_id = artist.id AND library_artist.stats != '{}'") - // Apply library filtering by joining only with accessible libraries - query = query.LeftJoin("library_artist on library_artist.artist_id = artist.id"). - Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) + // Admin users see all artists from all libraries, no additional filtering needed + if user.ID != invalidUserId && !user.IsAdmin { + // Apply library filtering only for non-admin users by joining with their accessible libraries + query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) + } return query } @@ -503,6 +505,15 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { totalRowsAffected += rowsAffected } + // // Remove library_artist entries for artists that no longer have any content in any library + cleanupSQL := Delete("library_artist").Where("stats = '{}'") + cleanupRows, err := r.executeSQL(cleanupSQL) + if err != nil { + log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err) + } else if cleanupRows > 0 { + log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + } + log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 2e19892a1..2259012ac 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,12 +3,10 @@ package persistence import ( "context" "encoding/json" - "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" @@ -16,6 +14,34 @@ import ( . "github.com/onsi/gomega" ) +// Test helper functions to reduce duplication +func createTestArtistWithMBID(id, name, mbid string) model.Artist { + return model.Artist{ + ID: id, + Name: name, + MbzArtistID: mbid, + } +} + +func createUserWithLibraries(userID string, libraryIDs []int) model.User { + user := model.User{ + ID: userID, + UserName: userID, + Name: userID, + Email: userID + "@test.com", + IsAdmin: false, + } + + if len(libraryIDs) > 0 { + user.Libraries = make(model.Libraries, len(libraryIDs)) + for i, libID := range libraryIDs { + user.Libraries[i] = model.Library{ID: libID, Name: "Test Library", Path: "/test"} + } + } + + return user +} + var _ = Describe("ArtistRepository", func() { Context("Core Functionality", func() { @@ -43,7 +69,7 @@ var _ = Describe("ArtistRepository", func() { func(role string, shouldBeValid bool) { result := roleFilter("", role) if shouldBeValid { - expectedExpr := squirrel.Expr("EXISTS (SELECT 1 FROM library_artist WHERE library_artist.artist_id = artist.id AND JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL)") + expectedExpr := squirrel.Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") Expect(result).To(Equal(expectedExpr)) } else { expectedInvalid := squirrel.Eq{"1": 2} @@ -158,8 +184,7 @@ var _ = Describe("ArtistRepository", func() { var repo model.ArtistRepository BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx := log.NewContext(context.TODO()) + ctx := GinkgoT().Context() ctx = request.WithUser(ctx, adminUser) repo = NewArtistRepository(ctx, GetDBXBuilder()) }) @@ -361,55 +386,119 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("MBID Search", func() { - var artistWithMBID model.Artist - var raw *artistRepository + Describe("MBID and Text Search", func() { + var lib2 model.Library + var lr model.LibraryRepository + var restrictedUser model.User + var restrictedRepo model.ArtistRepository + var headlessRepo model.ArtistRepository BeforeEach(func() { - raw = repo.(*artistRepository) - // Create a test artist with MBID - artistWithMBID = model.Artist{ - ID: "test-mbid-artist", - Name: "Test MBID Artist", - MbzArtistID: "550e8400-e29b-41d4-a716-446655440010", // Valid UUID v4 - } + // Set up headless repo (no user context) + headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder()) - // Insert the test artist into the database with proper library association - err := createArtistWithLibrary(repo, &artistWithMBID, 1) + // Create library for testing access restrictions + lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} + lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err := lr.Put(&lib2) + Expect(err).ToNot(HaveOccurred()) + + // Create a user with access to only library 1 + restrictedUser = createUserWithLibraries("search_user", []int{1}) + + // Create repository context for the restricted user + ctx := request.WithUser(GinkgoT().Context(), restrictedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) + + // Ensure both test artists are associated with library 1 + err = lr.AddArtist(1, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + err = lr.AddArtist(1, artistKraftwerk.ID) + Expect(err).ToNot(HaveOccurred()) + + // Create the restricted user in the database + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err = ur.Put(&restrictedUser) + Expect(err).ToNot(HaveOccurred()) + err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { - // Clean up test data using direct SQL - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + // Clean up library 2 + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) }) - It("finds artist by mbz_artist_id", func() { - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) + DescribeTable("MBID search behavior across different user types", + func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) { + // Create test artist with MBID + artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010") + + err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1) + Expect(err).ToNot(HaveOccurred()) + + // Test the search + results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + + if shouldFind { + Expect(results).To(HaveLen(1), testDesc) + Expect(results[0].ID).To(Equal("test-mbid-artist")) + } else { + Expect(results).To(BeEmpty(), testDesc) + } + + // Clean up + if raw, ok := (*testRepo).(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + } + }, + Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"), + Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"), + Entry("Headless process can find artist by MBID", &headlessRepo, true, "Headless process should find MBID artist"), + ) + + It("prevents restricted user from finding artist by MBID when not in accessible library", func() { + // Create an artist in library 2 (not accessible to restricted user) + inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711") + err := repo.Put(&inaccessibleArtist) Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-mbid-artist")) - Expect(results[0].Name).To(Equal("Test MBID Artist")) - }) - It("returns empty result when MBID is not found", func() { - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + // Add to library 2 (not accessible to restricted user) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) + + // But admin should find it + results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + + // Clean up + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + } }) It("handles includeMissing parameter for MBID search", func() { // Create a missing artist with MBID - missingArtist := model.Artist{ - ID: "test-missing-mbid-artist", - Name: "Test Missing MBID Artist", - MbzArtistID: "550e8400-e29b-41d4-a716-446655440012", - Missing: true, - } + missingArtist := createTestArtistWithMBID("test-missing-mbid-artist", "Test Missing MBID Artist", "550e8400-e29b-41d4-a716-446655440012") + missingArtist.Missing = true err := createArtistWithLibrary(repo, &missingArtist, 1) Expect(err).ToNot(HaveOccurred()) + // Mark as missing + if raw, ok := repo.(*artistRepository); ok { + _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) + Expect(err).ToNot(HaveOccurred()) + } + // Should not find missing artist when includeMissing is false results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) Expect(err).ToNot(HaveOccurred()) @@ -422,7 +511,95 @@ var _ = Describe("ArtistRepository", func() { Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) // Clean up - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + } + }) + + Context("Text Search", func() { + It("allows admin to find artists by name regardless of library", func() { + results, err := repo.Search("Beatles", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Name).To(Equal("The Beatles")) + }) + + It("correctly prevents restricted user from finding artists by name when not in accessible library", func() { + // Create an artist in library 2 (not accessible to restricted user) + inaccessibleArtist := model.Artist{ + ID: "inaccessible-text-artist", + Name: "Unique Search Name Artist", + } + err := repo.Put(&inaccessibleArtist) + Expect(err).ToNot(HaveOccurred()) + + // Add to library 2 (not accessible to restricted user) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("Unique Search Name", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty(), "Text search should respect library filtering") + + // Clean up + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + } + }) + }) + + Context("Headless Processes (No User Context)", func() { + It("should see all artists from all libraries when no user is in context", func() { + // Add artists to different libraries + err := lr.AddArtist(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Headless processes should see all artists regardless of library + artists, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + + // Should see all artists from all libraries + found := false + for _, artist := range artists { + if artist.ID == artistBeatles.ID { + found = true + break + } + } + Expect(found).To(BeTrue(), "Headless process should see artists from all libraries") + }) + + It("should allow headless processes to apply explicit library_id filters", func() { + // Add artists to different libraries + err := lr.AddArtist(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Filter by specific library + artists, err := headlessRepo.GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + + // Should see only artists from the specified library + for _, artist := range artists { + if artist.ID == artistBeatles.ID { + return // Found the expected artist + } + } + Expect(false).To(BeTrue(), "Should find artist from specified library") + }) + + It("should get individual artists when no user is in context", func() { + // Add artist to a library + err := lr.AddArtist(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Headless process should be able to get the artist + artist, err := headlessRepo.Get(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(artistBeatles.ID)) + }) }) }) @@ -441,6 +618,45 @@ var _ = Describe("ArtistRepository", func() { Expect(exists).To(BeTrue()) }) }) + + Describe("Missing Artist Handling", func() { + var missingArtist model.Artist + var raw *artistRepository + + BeforeEach(func() { + raw = repo.(*artistRepository) + missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"} + + // Create and mark as missing + err := createArtistWithLibrary(repo, &missingArtist, 1) + Expect(err).ToNot(HaveOccurred()) + + _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + }) + + It("admin can see missing artists when explicitly included", func() { + // Should see missing artist in GetAll by default for admin users + artists, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(3)) // Including the missing artist + + // Should see missing artist when searching with includeMissing=true + results, err := repo.Search("Missing Artist", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("missing_test")) + + // Should not see missing artist when searching with includeMissing=false + results, err = repo.Search("Missing Artist", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) }) Context("Regular User Operations", func() { @@ -448,12 +664,11 @@ var _ = Describe("ArtistRepository", func() { var unauthorizedUser model.User BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) // Create a user without access to any libraries unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false} // Create repository context for the unauthorized user - ctx := log.NewContext(context.TODO()) + ctx := GinkgoT().Context() ctx = request.WithUser(ctx, unauthorizedUser) restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) }) @@ -509,8 +724,9 @@ var _ = Describe("ArtistRepository", func() { Context("when user gains library access", func() { BeforeEach(func() { + ctx := GinkgoT().Context() // Give the user access to library 1 - ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder()) // First create the user if not exists err := ur.Put(&unauthorizedUser) @@ -526,14 +742,13 @@ var _ = Describe("ArtistRepository", func() { unauthorizedUser.Libraries = libraries // Recreate repository context with updated user - ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, unauthorizedUser) restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) }) AfterEach(func() { // Clean up: remove the user's library access - ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{}) }) @@ -578,292 +793,6 @@ var _ = Describe("ArtistRepository", func() { }) }) }) - - Context("Permission-Based Behavior Comparison", func() { - Describe("Missing Artist Visibility", func() { - var repo model.ArtistRepository - var raw *artistRepository - var missing model.Artist - - insertMissing := func() { - missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"} - Expect(repo.Put(&missing)).To(Succeed()) - raw = repo.(*artistRepository) - _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID})) - Expect(err).ToNot(HaveOccurred()) - - // Add missing artist to library 1 so it can be found by library filtering - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - err = lr.AddArtist(1, missing.ID) - Expect(err).ToNot(HaveOccurred()) - - // Ensure the test user exists and has library access - ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - currentUser, ok := request.UserFrom(repo.(*artistRepository).ctx) - if ok { - // Create the user if it doesn't exist with default values if missing - testUser := model.User{ - ID: currentUser.ID, - UserName: currentUser.UserName, - Name: currentUser.Name, - Email: currentUser.Email, - IsAdmin: currentUser.IsAdmin, - } - // Provide defaults for missing fields - if testUser.UserName == "" { - testUser.UserName = testUser.ID - } - if testUser.Name == "" { - testUser.Name = testUser.ID - } - if testUser.Email == "" { - testUser.Email = testUser.ID + "@test.com" - } - - // Try to put the user (will fail silently if already exists) - _ = ur.Put(&testUser) - - // Add library association using SetUserLibraries - err = ur.SetUserLibraries(currentUser.ID, []int{1}) - // Ignore error if user already has these libraries or other conflicts - if err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") && !strings.Contains(err.Error(), "duplicate key") { - Expect(err).ToNot(HaveOccurred()) - } - } - } - - removeMissing := func() { - if raw != nil { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID})) - } - } - - Context("regular user", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Create user with library access (simulating middleware behavior) - regularUserWithLibs := model.User{ - ID: "u1", - IsAdmin: false, - Libraries: model.Libraries{ - {ID: 1, Name: "Test Library", Path: "/test"}, - }, - } - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, regularUserWithLibs) - repo = NewArtistRepository(ctx, GetDBXBuilder()) - insertMissing() - }) - - AfterEach(func() { removeMissing() }) - - It("does not return missing artist in GetAll", func() { - artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}}) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(2)) - }) - - It("does not return missing artist in Search", func() { - res, err := repo.Search("missing", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(BeEmpty()) - }) - - It("does not return missing artist in GetIndex", func() { - idx, err := repo.GetIndex(false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - // Only 2 artists should be present - total := 0 - for _, ix := range idx { - total += len(ix.Artists) - } - Expect(total).To(Equal(2)) - }) - }) - - Context("admin user", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true}) - repo = NewArtistRepository(ctx, GetDBXBuilder()) - insertMissing() - }) - - AfterEach(func() { removeMissing() }) - - It("returns missing artist in GetAll", func() { - artists, err := repo.GetAll() - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(3)) - }) - - It("returns missing artist in Search", func() { - res, err := repo.Search("missing", 0, 10, true) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(HaveLen(1)) - }) - - It("returns missing artist in GetIndex when included", func() { - idx, err := repo.GetIndex(true, []int{1}) - Expect(err).ToNot(HaveOccurred()) - total := 0 - for _, ix := range idx { - total += len(ix.Artists) - } - Expect(total).To(Equal(3)) - }) - }) - }) - - Describe("Library Filtering", func() { - var restrictedUser model.User - var restrictedRepo model.ArtistRepository - var adminRepo model.ArtistRepository - var lib2 model.Library - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - // Set up admin repo - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) - adminRepo = NewArtistRepository(ctx, GetDBXBuilder()) - - // Create library for testing access restrictions - lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - err := lr.Put(&lib2) - Expect(err).ToNot(HaveOccurred()) - - // Create a user with access to only library 1 - restrictedUser = model.User{ - ID: "search_user", - IsAdmin: false, - Libraries: model.Libraries{ - {ID: 1, Name: "Library 1", Path: "/lib1"}, - }, - } - - // Create repository context for the restricted user - ctx = log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) - - // Ensure both test artists are associated with library 1 - err = lr.AddArtist(1, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - err = lr.AddArtist(1, artistKraftwerk.ID) - Expect(err).ToNot(HaveOccurred()) - - // Create the restricted user in the database - ur := NewUserRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - err = ur.Put(&restrictedUser) - Expect(err).ToNot(HaveOccurred()) - err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - // Clean up library 2 - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) - }) - - Context("MBID Search", func() { - var artistWithMBID model.Artist - - BeforeEach(func() { - artistWithMBID = model.Artist{ - ID: "search-mbid-artist", - Name: "Search MBID Artist", - MbzArtistID: "f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", - } - err := createArtistWithLibrary(adminRepo, &artistWithMBID, 1) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - raw := adminRepo.(*artistRepository) - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) - }) - - It("allows admin to find artist by MBID regardless of library", func() { - results, err := adminRepo.Search("f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("search-mbid-artist")) - }) - - It("allows restricted user to find artist by MBID when in accessible library", func() { - results, err := restrictedRepo.Search("f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("search-mbid-artist")) - }) - - It("prevents restricted user from finding artist by MBID when not in accessible library", func() { - // Create an artist in library 2 (not accessible to restricted user) - inaccessibleArtist := model.Artist{ - ID: "inaccessible-mbid-artist", - Name: "Inaccessible MBID Artist", - MbzArtistID: "a74b1b7f-71a5-4011-9441-d0b5e4122711", - } - err := adminRepo.Put(&inaccessibleArtist) - Expect(err).ToNot(HaveOccurred()) - - // Add to library 2 (not accessible to restricted user) - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) - Expect(err).ToNot(HaveOccurred()) - - // Restricted user should not find this artist - results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - - // Clean up - raw := adminRepo.(*artistRepository) - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) - }) - }) - - Context("Text Search", func() { - It("allows admin to find artists by name regardless of library", func() { - results, err := adminRepo.Search("Beatles", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("The Beatles")) - }) - - It("correctly prevents restricted user from finding artists by name when not in accessible library", func() { - // Create an artist in library 2 (not accessible to restricted user) - inaccessibleArtist := model.Artist{ - ID: "inaccessible-text-artist", - Name: "Unique Search Name Artist", - } - err := adminRepo.Put(&inaccessibleArtist) - Expect(err).ToNot(HaveOccurred()) - - // Add to library 2 (not accessible to restricted user) - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) - err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) - Expect(err).ToNot(HaveOccurred()) - - // Restricted user should not find this artist - results, err := restrictedRepo.Search("Unique Search Name", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - - // Text search correctly respects library filtering - Expect(results).To(BeEmpty(), "Text search should respect library filtering") - - // Clean up - raw := adminRepo.(*artistRepository) - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) - }) - }) - }) - }) }) // Helper function to create an artist with proper library association. @@ -875,6 +804,6 @@ func createArtistWithLibrary(repo model.ArtistRepository, artist *model.Artist, } // Add the artist to the specified library - lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder()) + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) return lr.AddArtist(libraryID, artist.ID) } diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 311eb0a68..5857350a6 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -21,7 +21,7 @@ func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreReposito } func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { - return r.newSelect(opt...) + return r.newSelect(opt...).Columns("tag.tag_value as name") } func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index e7b43689c..67e84ce51 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -7,8 +7,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" @@ -23,8 +21,7 @@ var _ = Describe("GenreRepository", func() { var ctx context.Context BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) genreRepo := NewGenreRepository(ctx, GetDBXBuilder()) repo = genreRepo restRepo = genreRepo.(model.ResourceRepository) @@ -240,6 +237,82 @@ var _ = Describe("GenreRepository", func() { }) }) + Describe("Library Filtering", func() { + Context("Headless Processes (No User Context)", func() { + var headlessRepo model.GenreRepository + var headlessRestRepo model.ResourceRepository + + BeforeEach(func() { + // Create a repository with no user context (headless) + headlessGenreRepo := NewGenreRepository(context.Background(), GetDBXBuilder()) + headlessRepo = headlessGenreRepo + headlessRestRepo = headlessGenreRepo.(model.ResourceRepository) + + // Add genres to different libraries + db := GetDBXBuilder() + _, err := db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Test Library 2', '/test2')").Execute() + Expect(err).ToNot(HaveOccurred()) + + // Add tags to different libraries + newTag := func(name, value string) model.Tag { + return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + } + + err = tagRepo.Add(2, newTag("genre", "jazz")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should see all genres from all libraries when no user is in context", func() { + // Headless processes should see all genres regardless of library + genres, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + + // Should see genres from all libraries + var genreNames []string + for _, genre := range genres { + genreNames = append(genreNames, genre.Name) + } + + // Should include both rock (library 1) and jazz (library 2) + Expect(genreNames).To(ContainElement("rock")) + Expect(genreNames).To(ContainElement("jazz")) + }) + + It("should count all genres from all libraries when no user is in context", func() { + count, err := headlessRestRepo.Count() + Expect(err).ToNot(HaveOccurred()) + + // Should count all genres from all libraries + Expect(count).To(BeNumerically(">=", 2)) + }) + + It("should allow headless processes to apply explicit library_id filters", func() { + // Filter by specific library + genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": 2}, + }) + Expect(err).ToNot(HaveOccurred()) + + genreList := genres.(model.Genres) + // Should see only genres from library 2 + Expect(genreList).To(HaveLen(1)) + Expect(genreList[0].Name).To(Equal("jazz")) + }) + + It("should get individual genres when no user is in context", func() { + // Get all genres first to find an ID + genres, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(genres).ToNot(BeEmpty()) + + // Headless process should be able to get the genre + genre, err := headlessRestRepo.Read(genres[0].ID) + Expect(err).ToNot(HaveOccurred()) + Expect(genre).ToNot(BeNil()) + }) + }) + }) + Describe("EntityName", func() { It("should return correct entity name", func() { name := restRepo.EntityName() diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index a3d5ebc74..1007d84fe 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -218,7 +218,13 @@ var _ = BeforeSuite(func() { if err := arr.SetStar(true, artistBeatles.ID); err != nil { panic(err) } - ar, _ := arr.Get(artistBeatles.ID) + ar, err := arr.Get(artistBeatles.ID) + if err != nil { + panic(err) + } + if ar == nil { + panic("artist not found after SetStar") + } artistBeatles.Starred = true artistBeatles.StarredAt = ar.StarredAt testArtists[1] = artistBeatles @@ -230,6 +236,9 @@ var _ = BeforeSuite(func() { if err != nil { panic(err) } + if al == nil { + panic("album not found after SetStar") + } albumRadioactivity.Starred = true albumRadioactivity.StarredAt = al.StarredAt testAlbums[2] = albumRadioactivity diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index ac0d8adeb..d0f88903e 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -8,7 +8,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" "github.com/pocketbase/dbx" ) @@ -83,20 +82,7 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S if err != nil { return nil, err } - - // Create context with user information for getParticipants call - // This is needed because the artist repository requires user context for multi-library support - userRepo := NewUserRepository(r.ctx, r.db) - user, err := userRepo.Get(res.ScrobbleEntry.UserID) - if err != nil { - return nil, err - } - // Temporarily use user context for getParticipants - originalCtx := r.ctx - r.ctx = request.WithUser(r.ctx, *user) res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) - r.ctx = originalCtx // Restore original context - if err != nil { return nil, err } diff --git a/persistence/share_repository.go b/persistence/share_repository.go index abe1ea6e6..d943943e0 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -95,7 +95,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return err case "album": albumRepo := NewAlbumRepository(r.ctx, r.db) - share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"id": ids})}) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) if err != nil { return err } diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go new file mode 100644 index 000000000..252115175 --- /dev/null +++ b/persistence/share_repository_test.go @@ -0,0 +1,133 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ShareRepository", func() { + var repo model.ShareRepository + var ctx context.Context + var adminUser = model.User{ID: "admin", UserName: "admin", IsAdmin: true} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewShareRepository(ctx, GetDBXBuilder()) + + // Insert the admin user into the database (required for foreign key constraint) + ur := NewUserRepository(ctx, GetDBXBuilder()) + err := ur.Put(&adminUser) + Expect(err).ToNot(HaveOccurred()) + + // Clean up shares + db := GetDBXBuilder() + _, err = db.NewQuery("DELETE FROM share").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Headless Access", func() { + Context("Repository creation and basic operations", func() { + It("should create repository successfully with no user context", func() { + // Create repository with no user context (headless) + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + Expect(headlessRepo).ToNot(BeNil()) + }) + + It("should handle GetAll for headless processes", func() { + // Create a simple share directly in database + shareID := "headless-test-share" + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]interface{}{ + "id": shareID, + "user": adminUser.ID, + "desc": "Headless Test Share", + "type": "song", + "ids": "song-1", + "created": time.Now(), + "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Headless process should see all shares + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + + found := false + for _, s := range shares { + if s.ID == shareID { + found = true + break + } + } + Expect(found).To(BeTrue(), "Headless process should see all shares") + }) + + It("should handle individual share retrieval for headless processes", func() { + // Create a simple share + shareID := "headless-get-share" + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]interface{}{ + "id": shareID, + "user": adminUser.ID, + "desc": "Headless Get Share", + "type": "song", + "ids": "song-2", + "created": time.Now(), + "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Headless process should be able to get the share + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + share, err := headlessRepo.Get(shareID) + Expect(err).ToNot(HaveOccurred()) + Expect(share.ID).To(Equal(shareID)) + Expect(share.Description).To(Equal("Headless Get Share")) + }) + }) + }) + + Describe("SQL ambiguity fix verification", func() { + It("should handle share operations without SQL ambiguity errors", func() { + // This test verifies that the loadMedia function doesn't cause SQL ambiguity + // The key fix was using "album.id" instead of "id" in the album query filters + + // Create a share that would trigger the loadMedia function + shareID := "sql-test-share" + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]interface{}{ + "id": shareID, + "user": adminUser.ID, + "desc": "SQL Test Share", + "type": "album", + "ids": "non-existent-album", // Won't find albums, but shouldn't cause SQL errors + "created": time.Now(), + "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // The Get operation should work without SQL ambiguity errors + // even if no albums are found + share, err := repo.Get(shareID) + Expect(err).ToNot(HaveOccurred()) + Expect(share.ID).To(Equal(shareID)) + // Albums array should be empty since we used non-existent album ID + Expect(share.Albums).To(BeEmpty()) + }) + }) +}) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 9e7a58713..ce026a3c3 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -205,27 +205,20 @@ func libraryIdFilter(_ string, value interface{}) Sqlizer { func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder { user := loggedUser(r.ctx) - // Admin users see all content - if user.IsAdmin { + // If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter + if user.IsAdmin || user.ID == invalidUserId { return sq } - // Get user's accessible library IDs - userID := loggedUser(r.ctx).ID - if userID == invalidUserId { - // No user context - return empty result set - return sq.Where(Eq{"1": "0"}) - } - table := r.tableName if len(tableName) > 0 { table = tableName[0] } + // Get user's accessible library IDs // Use subquery to filter by user's library access - // This approach doesn't require DataStore in context return sq.Where(Expr(table+".library_id IN ("+ - "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", userID)) + "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID)) } func (r sqlRepository) seedKey() string { diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 7ba2a0021..b46e2066b 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -223,4 +223,62 @@ var _ = Describe("sqlRepository", func() { Expect(hasher.CurrentSeed(id)).To(Equal("seed")) }) }) + + Describe("applyLibraryFilter", func() { + var sq squirrel.SelectBuilder + + BeforeEach(func() { + sq = squirrel.Select("*").From("test_table") + }) + + Context("Admin User", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) + }) + + It("should not apply library filter for admin users", func() { + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + + Context("Regular User", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) + }) + + It("should apply library filter for regular users", func() { + result := r.applyLibraryFilter(sq) + sql, args, _ := result.ToSql() + Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) + Expect(args).To(ContainElement("user123")) + }) + + It("should use custom table name when provided", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, args, _ := result.ToSql() + Expect(sql).To(ContainSubstring("custom_table.library_id IN")) + Expect(args).To(ContainElement("user123")) + }) + }) + + Context("Headless Process (No User Context)", func() { + BeforeEach(func() { + r.ctx = context.Background() // No user context + }) + + It("should not apply library filter for headless processes", func() { + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + + It("should not apply library filter even with custom table name", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + }) }) diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index 006b7063b..4345184f1 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -68,7 +68,7 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) { ar := NewArtistRepository(r.ctx, r.db) ids := m.Participants.AllIDs() - artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"id": ids}}) + artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"artist.id": ids}}) if err != nil { return nil, fmt.Errorf("getting participants: %w", err) } diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index b92e18e60..8c3c1e89d 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -91,61 +91,66 @@ func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model. return r } +// applyLibraryFiltering adds the appropriate library joins based on user context +func (r *baseTagRepository) applyLibraryFiltering(sq SelectBuilder) SelectBuilder { + // Add library_tag join + sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id") + + // For authenticated users, also join with user_library to filter by accessible libraries + user := loggedUser(r.ctx) + if user.ID != invalidUserId { + sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) + } + + return sq +} + // newSelect overrides the base implementation to apply tag name filtering and library filtering. func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder { - user := loggedUser(r.ctx) - if user.ID == invalidUserId { - // No user context - return empty result set - return SelectBuilder{}.Where(Eq{"1": "0"}) - } sq := r.sqlRepository.newSelect(options...) + + // Apply tag name filtering if specified if r.tagFilter != nil { sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) } - sq = sq.Columns( + + // Apply library filtering and set up aggregation columns + sq = r.applyLibraryFiltering(sq).Columns( "tag.id", - "tag.tag_value as name", + "tag.tag_name", + "tag.tag_value", "COALESCE(SUM(library_tag.album_count), 0) as album_count", "COALESCE(SUM(library_tag.media_file_count), 0) as song_count", - ). - LeftJoin("library_tag on library_tag.tag_id = tag.id"). - // Apply library filtering by joining only with accessible libraries - Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID). - GroupBy("tag.id", "tag.tag_value") + ).GroupBy("tag.id", "tag.tag_name", "tag.tag_value") + return sq } // ResourceRepository interface implementation func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { - // Create a query that counts distinct tags without GROUP BY - user := loggedUser(r.ctx) - if user.ID == invalidUserId { - return 0, nil - } + sq := Select("COUNT(DISTINCT tag.id)").From("tag") - // Build the same base query as newSelect but for counting - sq := Select() + // Apply tag name filtering if specified if r.tagFilter != nil { sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) } - // Apply the same joins as newSelect - sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id"). - Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) + // Apply library filtering + sq = r.applyLibraryFiltering(sq) return r.count(sq, r.parseRestOptions(r.ctx, options...)) } func (r *baseTagRepository) Read(id string) (interface{}, error) { - query := r.newSelect().Columns("*").Where(Eq{"id": id}) + query := r.newSelect().Where(Eq{"id": id}) var res model.Tag err := r.queryOne(query, &res) return &res, err } func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { - query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") + query := r.newSelect(r.parseRestOptions(r.ctx, options...)) var res model.TagList err := r.queryAll(query, &res) return res, err diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go index 8017528fe..ab0d57d52 100644 --- a/persistence/tag_library_filtering_test.go +++ b/persistence/tag_library_filtering_test.go @@ -14,214 +14,245 @@ import ( "github.com/pocketbase/dbx" ) +const ( + adminUserID = "userid" + regularUserID = "2222" + libraryID1 = 1 + libraryID2 = 2 + libraryID3 = 3 + + tagNameGenre = "genre" + tagValueRock = "rock" + tagValuePop = "pop" + tagValueJazz = "jazz" +) + var _ = Describe("Tag Library Filtering", func() { + var ( + tagRockID = id.NewTagID(tagNameGenre, tagValueRock) + tagPopID = id.NewTagID(tagNameGenre, tagValuePop) + tagJazzID = id.NewTagID(tagNameGenre, tagValueJazz) + ) + + expectTagValues := func(tagList model.TagList, expected []string) { + tagValues := make([]string, len(tagList)) + for i, tag := range tagList { + tagValues[i] = tag.TagValue + } + Expect(tagValues).To(ContainElements(expected)) + } BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - // Clean up all relevant tables + // Clean up database db := GetDBXBuilder() _, err := db.NewQuery("DELETE FROM library_tag").Execute() Expect(err).ToNot(HaveOccurred()) _, err = db.NewQuery("DELETE FROM tag").Execute() Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("DELETE FROM user_library WHERE user_id != 'userid' AND user_id != '2222'").Execute() + _, err = db.NewQuery("DELETE FROM user_library WHERE user_id != {:admin} AND user_id != {:regular}"). + Bind(dbx.Params{"admin": adminUserID, "regular": regularUserID}).Execute() Expect(err).ToNot(HaveOccurred()) _, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute() Expect(err).ToNot(HaveOccurred()) // Create test libraries - _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (2, 'Library 2', '/music/lib2')").Execute() + _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). + Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute() Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (3, 'Library 3', '/music/lib3')").Execute() + _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). + Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute() Expect(err).ToNot(HaveOccurred()) - // Ensure admin user has access to all libraries (since admin users should have access to all libraries) - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 2)").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 3)").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Set up test tags - newTag := func(name, value string) model.Tag { - return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + // Give admin access to all libraries + for _, libID := range []int{libraryID1, libraryID2, libraryID3} { + _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})"). + Bind(dbx.Params{"user": adminUserID, "lib": libID}).Execute() + Expect(err).ToNot(HaveOccurred()) } - // Create tags in admin context + // Create test tags adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) - // Add tags to different libraries - err = tagRepo.Add(1, newTag("genre", "rock")) - Expect(err).ToNot(HaveOccurred()) - err = tagRepo.Add(2, newTag("genre", "pop")) - Expect(err).ToNot(HaveOccurred()) - err = tagRepo.Add(3, newTag("genre", "jazz")) - Expect(err).ToNot(HaveOccurred()) - err = tagRepo.Add(2, newTag("genre", "rock")) - Expect(err).ToNot(HaveOccurred()) + createTag := func(libraryID int, name, value string) { + tag := model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} + err := tagRepo.Add(libraryID, tag) + Expect(err).ToNot(HaveOccurred()) + } - // Update counts manually for testing - _, err = db.NewQuery("UPDATE library_tag SET album_count = 5, media_file_count = 20 WHERE tag_id = {:tagId} AND library_id = 1").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("UPDATE library_tag SET album_count = 3, media_file_count = 10 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "pop")}).Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("UPDATE library_tag SET album_count = 2, media_file_count = 8 WHERE tag_id = {:tagId} AND library_id = 3").Bind(dbx.Params{"tagId": id.NewTagID("genre", "jazz")}).Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("UPDATE library_tag SET album_count = 1, media_file_count = 4 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute() - Expect(err).ToNot(HaveOccurred()) + createTag(libraryID1, tagNameGenre, tagValueRock) + createTag(libraryID2, tagNameGenre, tagValuePop) + createTag(libraryID3, tagNameGenre, tagValueJazz) + createTag(libraryID2, tagNameGenre, tagValueRock) // Rock appears in both lib1 and lib2 - // Set up user library access - Regular user has access to libraries 1 and 2 only - _, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ('2222', 2)").Execute() + // Set tag counts (manually for testing) + setCounts := func(tagID string, libID, albums, songs int) { + _, err := db.NewQuery("UPDATE library_tag SET album_count = {:albums}, media_file_count = {:songs} WHERE tag_id = {:tag} AND library_id = {:lib}"). + Bind(dbx.Params{"albums": albums, "songs": songs, "tag": tagID, "lib": libID}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + setCounts(tagRockID, libraryID1, 5, 20) + setCounts(tagPopID, libraryID2, 3, 10) + setCounts(tagJazzID, libraryID3, 2, 8) + setCounts(tagRockID, libraryID2, 1, 4) + + // Give regular user access to library 2 only + _, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})"). + Bind(dbx.Params{"user": regularUserID, "lib": libraryID2}).Execute() Expect(err).ToNot(HaveOccurred()) }) Describe("TagRepository Library Filtering", func() { + // Helper to create repository and read all tags + readAllTags := func(user *model.User, filters ...rest.QueryOptions) model.TagList { + var ctx context.Context + if user != nil { + ctx = request.WithUser(log.NewContext(context.TODO()), *user) + } else { + ctx = context.Background() // Headless context + } + + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + var opts rest.QueryOptions + if len(filters) > 0 { + opts = filters[0] + } + + tags, err := repo.ReadAll(opts) + Expect(err).ToNot(HaveOccurred()) + return tags.(model.TagList) + } + + // Helper to count tags + countTags := func(user *model.User) int64 { + var ctx context.Context + if user != nil { + ctx = request.WithUser(log.NewContext(context.TODO()), *user) + } else { + ctx = context.Background() + } + + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + count, err := repo.Count() + Expect(err).ToNot(HaveOccurred()) + return count + } + Context("Admin User", func() { It("should see all tags regardless of library", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - tags, err := repo.ReadAll() - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - Expect(tagList).To(HaveLen(3)) + tags := readAllTags(&adminUser) + Expect(tags).To(HaveLen(3)) }) }) Context("Regular User with Limited Library Access", func() { It("should only see tags from accessible libraries", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - tags, err := repo.ReadAll() - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - + tags := readAllTags(®ularUser) // Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3) - Expect(tagList).To(HaveLen(2)) + Expect(tags).To(HaveLen(2)) }) It("should respect explicit library_id filters within accessible libraries", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - // Filter by library 2 (user has access to libraries 1 and 2) - tags, err := repo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{ - "library_id": 2, - }, + tags := readAllTags(®ularUser, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID2}, }) - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - // Should see only tags from library 2: pop and rock(lib2) - Expect(tagList).To(HaveLen(2)) - - // Verify the tags are correct - tagValues := make([]string, len(tagList)) - for i, tag := range tagList { - tagValues[i] = tag.TagValue - } - Expect(tagValues).To(ContainElements("pop", "rock")) + Expect(tags).To(HaveLen(2)) + expectTagValues(tags, []string{tagValuePop, tagValueRock}) }) It("should not return tags when filtering by inaccessible library", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - // Try to filter by library 3 (user doesn't have access) - tags, err := repo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{ - "library_id": 3, - }, + tags := readAllTags(®ularUser, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID3}, }) - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - // Should return no tags since user can't access library 3 - Expect(tagList).To(HaveLen(0)) + Expect(tags).To(HaveLen(0)) }) It("should filter by library 1 correctly", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), regularUser) - tagRepo := NewTagRepository(ctx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - // Filter by library 1 (user has access) - tags, err := repo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{ - "library_id": 1, - }, + tags := readAllTags(®ularUser, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID1}, }) - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - // Should see only rock from library 1 - Expect(tagList).To(HaveLen(1)) - Expect(tagList[0].TagValue).To(Equal("rock")) + Expect(tags).To(HaveLen(1)) + Expect(tags[0].TagValue).To(Equal(tagValueRock)) + }) + }) + + Context("Headless Processes (No User Context)", func() { + It("should see all tags from all libraries when no user is in context", func() { + tags := readAllTags(nil) // nil = headless context + // Should see all tags from all libraries (no filtering applied) + Expect(tags).To(HaveLen(3)) + expectTagValues(tags, []string{tagValueRock, tagValuePop, tagValueJazz}) + }) + + It("should count all tags from all libraries when no user is in context", func() { + count := countTags(nil) + // Should count all tags from all libraries + Expect(count).To(Equal(int64(3))) + }) + + It("should calculate proper statistics from all libraries for headless processes", func() { + tags := readAllTags(nil) + + // Find the rock tag (appears in libraries 1 and 2) + var rockTag *model.Tag + for _, tag := range tags { + if tag.TagValue == tagValueRock { + rockTag = &tag + break + } + } + Expect(rockTag).ToNot(BeNil()) + + // Should have stats from all libraries where rock appears + // Library 1: 5 albums, 20 songs + // Library 2: 1 album, 4 songs + // Total: 6 albums, 24 songs + Expect(rockTag.AlbumCount).To(Equal(6)) + Expect(rockTag.SongCount).To(Equal(24)) + }) + + It("should allow headless processes to apply explicit library_id filters", func() { + tags := readAllTags(nil, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID3}, + }) + // Should see only jazz from library 3 + Expect(tags).To(HaveLen(1)) + Expect(tags[0].TagValue).To(Equal(tagValueJazz)) }) }) Context("Admin User with Explicit Library Filtering", func() { It("should see all tags when no filter is applied", func() { - adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - tags, err := repo.ReadAll() - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - Expect(tagList).To(HaveLen(3)) + tags := readAllTags(&adminUser) + Expect(tags).To(HaveLen(3)) }) It("should respect explicit library_id filters", func() { - adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - // Filter by library 3 - tags, err := repo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{ - "library_id": 3, - }, + tags := readAllTags(&adminUser, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID3}, }) - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - // Should see only jazz from library 3 - Expect(tagList).To(HaveLen(1)) - Expect(tagList[0].TagValue).To(Equal("jazz")) + Expect(tags).To(HaveLen(1)) + Expect(tags[0].TagValue).To(Equal(tagValueJazz)) }) It("should filter by library 2 correctly", func() { - adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) - repo := tagRepo.(model.ResourceRepository) - - // Filter by library 2 - tags, err := repo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{ - "library_id": 2, - }, + tags := readAllTags(&adminUser, rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": libraryID2}, }) - Expect(err).ToNot(HaveOccurred()) - tagList := tags.(model.TagList) - // Should see pop and rock from library 2 - Expect(tagList).To(HaveLen(2)) - - tagValues := make([]string, len(tagList)) - for i, tag := range tagList { - tagValues[i] = tag.TagValue - } - Expect(tagValues).To(ContainElements("pop", "rock")) + Expect(tags).To(HaveLen(2)) + expectTagValues(tags, []string{tagValuePop, tagValueRock}) }) }) }) diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 7a14e9efe..e175763e3 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -132,8 +132,10 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { useResourceRefresh('artist') const role = filterValues?.role - const getCounter = (record, counter) => - role ? record?.stats[role]?.[counter] : record?.[counter] + const getCounter = (record, counter) => { + if (!record) return undefined + return role ? record?.stats?.[role]?.[counter] : record?.[counter] + } const getAlbumCount = (record) => getCounter(record, 'albumCount') const getSongCount = (record) => getCounter(record, 'songCount') const getSize = (record) => { From e9a8d7ed66bfd16ba458219d18aef55bf5346688 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 21 Jul 2025 16:33:17 -0400 Subject: [PATCH 17/64] fix: update stats format comment in selectArtist method Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/artist_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index da46d8a11..e2e1f83b3 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -185,7 +185,7 @@ func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) } func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { - // Stats Format: {"1": {"albumartist": {"songCount": 10, "albumCount": 5, "size": 1024}, "artist": {...}}, "2": {...}} + // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} query := r.newSelect(options...).Columns("artist.*", "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") From 36d73eec0db2783cd804a5e501b8f9e42a0d4cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 21 Jul 2025 22:55:28 -0400 Subject: [PATCH 18/64] fix(scanner): prevent foreign key constraint error in tag UpdateCounts (#4370) * fix: prevent foreign key constraint error in tag UpdateCounts Added JOIN clause with tag table in UpdateCounts SQL query to filter out tag IDs from JSON that don't exist in the tag table. This prevents 'FOREIGN KEY constraint failed' errors when the library_tag table tries to reference non-existent tag IDs during scanner operations. The fix ensures only valid tag references are counted while maintaining data integrity and preventing scanner failures during library updates. * test(tag): add regression tests for foreign key constraint fix Add comprehensive regression tests to prevent the foreign key constraint error when tag IDs in JSON data don't exist in the tag table. Tests cover both album and media file scenarios with non-existent tag IDs. - Test UpdateCounts() with albums containing non-existent tag IDs - Test UpdateCounts() with media files containing non-existent tag IDs - Verify operations complete without foreign key errors Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/tag_repository.go | 1 + persistence/tag_repository_test.go | 62 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 729208999..b224450ab 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -56,6 +56,7 @@ INSERT INTO library_tag (tag_id, library_id, %[1]s_count) SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count FROM %[1]s JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id' +JOIN tag ON tag.id = jt.value GROUP BY jt.value, %[1]s.library_id ON CONFLICT (tag_id, library_id) DO UPDATE SET %[1]s_count = excluded.%[1]s_count; diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go index 9b8f93cd9..c3947a9f7 100644 --- a/persistence/tag_repository_test.go +++ b/persistence/tag_repository_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("TagRepository", func() { @@ -135,6 +136,67 @@ var _ = Describe("TagRepository", func() { err = repo.UpdateCounts() Expect(err).ToNot(HaveOccurred()) }) + + It("should handle albums with non-existent tag IDs in JSON gracefully", func() { + // Regression test for foreign key constraint error + // Create an album with tag IDs in JSON that don't exist in tag table + db := GetDBXBuilder() + + // First, create a non-existent tag ID (this simulates tags in JSON that aren't in tag table) + nonExistentTagID := id.NewTagID("genre", "nonexistent-genre") + + // Create album with JSON containing the non-existent tag ID + albumWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"nonexistent-genre"}]}` + + // Insert album directly into database with the problematic JSON + _, err := db.NewQuery("INSERT INTO album (id, name, library_id, tags) VALUES ({:id}, {:name}, {:lib}, {:tags})"). + Bind(dbx.Params{ + "id": "test-album-bad-tags", + "name": "Album With Bad Tags", + "lib": 1, + "tags": albumWithBadTags, + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // This should not fail with foreign key constraint error + err = repo.UpdateCounts() + Expect(err).ToNot(HaveOccurred()) + + // Cleanup + _, err = db.NewQuery("DELETE FROM album WHERE id = {:id}"). + Bind(dbx.Params{"id": "test-album-bad-tags"}).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should handle media files with non-existent tag IDs in JSON gracefully", func() { + // Regression test for foreign key constraint error with media files + db := GetDBXBuilder() + + // Create a non-existent tag ID + nonExistentTagID := id.NewTagID("genre", "another-nonexistent-genre") + + // Create media file with JSON containing the non-existent tag ID + mediaFileWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"another-nonexistent-genre"}]}` + + // Insert media file directly into database with the problematic JSON + _, err := db.NewQuery("INSERT INTO media_file (id, title, library_id, tags) VALUES ({:id}, {:title}, {:lib}, {:tags})"). + Bind(dbx.Params{ + "id": "test-media-bad-tags", + "title": "Media File With Bad Tags", + "lib": 1, + "tags": mediaFileWithBadTags, + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // This should not fail with foreign key constraint error + err = repo.UpdateCounts() + Expect(err).ToNot(HaveOccurred()) + + // Cleanup + _, err = db.NewQuery("DELETE FROM media_file WHERE id = {:id}"). + Bind(dbx.Params{"id": "test-media-bad-tags"}).Execute() + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("Count", func() { From 39febfac28ffc984c185c02448d3f40ff1611a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 22 Jul 2025 14:35:12 -0400 Subject: [PATCH 19/64] fix(scanner): prevent foreign key constraint errors in album participant insertion (#4373) * fix: prevent foreign key constraint error in album participants Prevent foreign key constraint errors when album participants contain artist IDs that don't exist in the artist table. The updateParticipants method now filters out non-existent artist IDs before attempting to insert album_artists relationships. - Add defensive filtering in updateParticipants() to query existing artist IDs - Only insert relationships for artist IDs that exist in the artist table - Add comprehensive regression test for both albums and media files - Fixes scanner errors when JSON participant data contains stale artist references Signed-off-by: Deluan <deluan@navidrome.org> * fix: optimize foreign key handling in album artists insertion Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve participants foreign key tests Signed-off-by: Deluan <deluan@navidrome.org> * fix: clarify comments in album artists insertion query Signed-off-by: Deluan <deluan@navidrome.org> * test: add cleanup to album repository tests Added individual test cleanup to 4 album repository tests that create temporary artists and albums. This ensures proper test isolation by removing test data after each test completes, preventing test interference when running with shuffle mode. Each test now cleans up its own temporary data from the artist, library_artist, album, and album_artists tables using direct SQL deletion. Signed-off-by: Deluan <deluan@navidrome.org> * fix: refactor participant JSON handling for simpler and improved SQL processing Signed-off-by: Deluan <deluan@navidrome.org> * fix: update test command description in Makefile for clarity Signed-off-by: Deluan <deluan@navidrome.org> * fix: refactor album repository tests to use albumRepository type directly Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- Makefile | 2 +- persistence/album_repository_test.go | 254 +++++++++++++++++++++++++-- persistence/sql_participations.go | 42 ++++- 3 files changed, 279 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 90b4012f7..034015740 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ watch: ##@Development Start Go tests in watch mode (re-run when code changes) .PHONY: watch PKG ?= ./... -test: ##@Development Run Go tests +test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server go test -tags netgo $(PKG) .PHONY: test diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 529458c26..4be89bcb8 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,13 +1,12 @@ package persistence import ( - "context" "fmt" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" @@ -16,16 +15,16 @@ import ( ) var _ = Describe("AlbumRepository", func() { - var repo model.AlbumRepository + var albumRepo *albumRepository BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"}) - repo = NewAlbumRepository(ctx, GetDBXBuilder()) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) Describe("Get", func() { var Get = func(id string) (*model.Album, error) { - album, err := repo.Get(id) + album, err := albumRepo.Get(id) if album != nil { album.ImportedAt = time.Time{} } @@ -42,7 +41,7 @@ var _ = Describe("AlbumRepository", func() { Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { - albums, err := repo.GetAll(opts...) + albums, err := albumRepo.GetAll(opts...) for i := range albums { albums[i].ImportedAt = time.Time{} } @@ -83,12 +82,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute newID := id.NewRandom() - Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for i := 0; i < playCount; i++ { - Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } - album, err := repo.Get(newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -106,12 +105,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized newID := id.NewRandom() - Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for i := 0; i < playCount; i++ { - Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } - album, err := repo.Get(newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -283,6 +282,235 @@ var _ = Describe("AlbumRepository", func() { Expect(err).To(HaveOccurred()) }) }) + + Describe("Participant Foreign Key Handling", func() { + // albumArtistRecord represents a record in the album_artists table + type albumArtistRecord struct { + ArtistID string `db:"artist_id"` + Role string `db:"role"` + SubRole string `db:"sub_role"` + } + + var artistRepo *artistRepository + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + artistRepo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + // Helper to verify album_artists records + verifyAlbumArtists := func(albumID string, expected []albumArtistRecord) { + GinkgoHelper() + var actual []albumArtistRecord + sq := squirrel.Select("artist_id", "role", "sub_role"). + From("album_artists"). + Where(squirrel.Eq{"album_id": albumID}). + OrderBy("role", "artist_id", "sub_role") + + err := albumRepo.queryAll(sq, &actual) + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(Equal(expected)) + } + + It("verifies that participant records are actually inserted into database", func() { + // Create a real artist in the database first + artist := &model.Artist{ + ID: "real-artist-1", + Name: "Real Artist", + OrderArtistName: "real artist", + SortArtistName: "Artist, Real", + } + err := createArtistWithLibrary(artistRepo, artist, 1) + Expect(err).ToNot(HaveOccurred()) + + // Create an album with participants that reference the real artist + album := &model.Album{ + LibraryID: 1, + ID: "test-album-db-insert", + Name: "Test Album DB Insert", + AlbumArtistID: "real-artist-1", + AlbumArtist: "Real Artist", + Participants: model.Participants{ + model.RoleArtist: { + {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}}, + }, + model.RoleComposer: { + {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}, SubRole: "primary"}, + }, + }, + } + + // Insert the album + err = albumRepo.Put(album) + Expect(err).ToNot(HaveOccurred()) + + // Verify that participant records were actually inserted into album_artists table + expected := []albumArtistRecord{ + {ArtistID: "real-artist-1", Role: "artist", SubRole: ""}, + {ArtistID: "real-artist-1", Role: "composer", SubRole: "primary"}, + } + verifyAlbumArtists(album.ID, expected) + + // Clean up the test artist and album created for this test + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + It("filters out invalid artist IDs leaving only valid participants in database", func() { + // Create two real artists in the database + artist1 := &model.Artist{ + ID: "real-artist-mix-1", + Name: "Real Artist 1", + OrderArtistName: "real artist 1", + } + artist2 := &model.Artist{ + ID: "real-artist-mix-2", + Name: "Real Artist 2", + OrderArtistName: "real artist 2", + } + err := createArtistWithLibrary(artistRepo, artist1, 1) + Expect(err).ToNot(HaveOccurred()) + err = createArtistWithLibrary(artistRepo, artist2, 1) + Expect(err).ToNot(HaveOccurred()) + + // Create an album with mix of valid and invalid artist IDs + album := &model.Album{ + LibraryID: 1, + ID: "test-album-mixed-validity", + Name: "Test Album Mixed Validity", + AlbumArtistID: "real-artist-mix-1", + AlbumArtist: "Real Artist 1", + Participants: model.Participants{ + model.RoleArtist: { + {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}}, + {Artist: model.Artist{ID: "non-existent-mix-1", Name: "Non Existent 1"}}, + {Artist: model.Artist{ID: "real-artist-mix-2", Name: "Real Artist 2"}}, + }, + model.RoleComposer: { + {Artist: model.Artist{ID: "non-existent-mix-2", Name: "Non Existent 2"}}, + {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}}, + }, + }, + } + + // This should not fail - only valid artists should be inserted + err = albumRepo.Put(album) + Expect(err).ToNot(HaveOccurred()) + + // Verify that only valid artist IDs were inserted into album_artists table + // Non-existent artists should be filtered out by the INNER JOIN + expected := []albumArtistRecord{ + {ArtistID: "real-artist-mix-1", Role: "artist", SubRole: ""}, + {ArtistID: "real-artist-mix-2", Role: "artist", SubRole: ""}, + {ArtistID: "real-artist-mix-1", Role: "composer", SubRole: ""}, + } + verifyAlbumArtists(album.ID, expected) + + // Clean up the test artists and album created for this test + artistIDs := []string{artist1.ID, artist2.ID} + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + It("handles complex nested JSON with multiple roles and sub-roles", func() { + // Create 4 artists for this test + artists := []*model.Artist{ + {ID: "complex-artist-1", Name: "Lead Vocalist", OrderArtistName: "lead vocalist"}, + {ID: "complex-artist-2", Name: "Guitarist", OrderArtistName: "guitarist"}, + {ID: "complex-artist-3", Name: "Producer", OrderArtistName: "producer"}, + {ID: "complex-artist-4", Name: "Engineer", OrderArtistName: "engineer"}, + } + + for _, artist := range artists { + err := createArtistWithLibrary(artistRepo, artist, 1) + Expect(err).ToNot(HaveOccurred()) + } + + // Create album with complex participant structure + album := &model.Album{ + LibraryID: 1, + ID: "test-album-complex-json", + Name: "Test Album Complex JSON", + AlbumArtistID: "complex-artist-1", + AlbumArtist: "Lead Vocalist", + Participants: model.Participants{ + model.RoleArtist: { + {Artist: model.Artist{ID: "complex-artist-1", Name: "Lead Vocalist"}}, + {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "lead guitar"}, + {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "rhythm guitar"}, + }, + model.RoleProducer: { + {Artist: model.Artist{ID: "complex-artist-3", Name: "Producer"}, SubRole: "executive"}, + }, + model.RoleEngineer: { + {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mixing"}, + {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mastering"}, + }, + }, + } + + err := albumRepo.Put(album) + Expect(err).ToNot(HaveOccurred()) + + // Verify complex JSON structure was correctly parsed and inserted + expected := []albumArtistRecord{ + {ArtistID: "complex-artist-1", Role: "artist", SubRole: ""}, + {ArtistID: "complex-artist-2", Role: "artist", SubRole: "lead guitar"}, + {ArtistID: "complex-artist-2", Role: "artist", SubRole: "rhythm guitar"}, + {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mastering"}, + {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mixing"}, + {ArtistID: "complex-artist-3", Role: "producer", SubRole: "executive"}, + } + verifyAlbumArtists(album.ID, expected) + + // Clean up the test artists and album created for this test + artistIDs := make([]string, len(artists)) + for i, artist := range artists { + artistIDs[i] = artist.ID + } + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + It("handles albums with non-existent artist IDs without constraint errors", func() { + // Regression test for foreign key constraint error when album participants + // contain artist IDs that don't exist in the artist table + + // Create an album with participants that reference non-existent artist IDs + album := &model.Album{ + LibraryID: 1, + ID: "test-album-fk-constraints", + Name: "Test Album with Invalid Artist References", + AlbumArtistID: "non-existent-artist-1", + AlbumArtist: "Non Existent Album Artist", + Participants: model.Participants{ + model.RoleArtist: { + {Artist: model.Artist{ID: "non-existent-artist-1", Name: "Non Existent Artist 1"}}, + {Artist: model.Artist{ID: "non-existent-artist-2", Name: "Non Existent Artist 2"}}, + }, + model.RoleComposer: { + {Artist: model.Artist{ID: "non-existent-composer-1", Name: "Non Existent Composer 1"}}, + {Artist: model.Artist{ID: "non-existent-composer-2", Name: "Non Existent Composer 2"}}, + }, + model.RoleAlbumArtist: { + {Artist: model.Artist{ID: "non-existent-album-artist-1", Name: "Non Existent Album Artist 1"}}, + }, + }, + } + + // This should not fail with foreign key constraint error + // The updateParticipants method should handle non-existent artist IDs gracefully + err := albumRepo.Put(album) + Expect(err).ToNot(HaveOccurred()) + + // Verify that no participant records were inserted since all artist IDs were invalid + // The INNER JOIN with the artist table should filter out all non-existent artists + verifyAlbumArtists(album.ID, []albumArtistRecord{}) + + // Clean up the test album created for this test + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index 4345184f1..d88eca45e 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -15,6 +15,13 @@ type participant struct { SubRole string `json:"subRole,omitempty"` } +// flatParticipant represents a flattened participant structure for SQL processing +type flatParticipant struct { + ArtistID string `json:"artist_id"` + Role string `json:"role"` + SubRole string `json:"sub_role,omitempty"` +} + func marshalParticipants(participants model.Participants) string { dbParticipants := make(map[model.Role][]participant) for role, artists := range participants { @@ -53,15 +60,40 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part if len(participants) == 0 { return nil } - sqi := Insert(r.tableName+"_artists"). - Columns(r.tableName+"_id", "artist_id", "role", "sub_role"). - Suffix(fmt.Sprintf("on conflict (artist_id, %s_id, role, sub_role) do nothing", r.tableName)) + + var flatParticipants []flatParticipant for role, artists := range participants { for _, artist := range artists { - sqi = sqi.Values(itemID, artist.ID, role.String(), artist.SubRole) + flatParticipants = append(flatParticipants, flatParticipant{ + ArtistID: artist.ID, + Role: role.String(), + SubRole: artist.SubRole, + }) } } - _, err = r.executeSQL(sqi) + + participantsJSON, err := json.Marshal(flatParticipants) + if err != nil { + return fmt.Errorf("marshaling participants: %w", err) + } + + // Build the INSERT query using json_each and INNER JOIN to artist table + // to automatically filter out non-existent artist IDs + query := fmt.Sprintf(` + INSERT INTO %[1]s_artists (%[1]s_id, artist_id, role, sub_role) + SELECT ?, + json_extract(value, '$.artist_id') as artist_id, + json_extract(value, '$.role') as role, + COALESCE(json_extract(value, '$.sub_role'), '') as sub_role + -- Parse the flat JSON array: [{"artist_id": "id", "role": "role", "sub_role": "subRole"}] + FROM json_each(?) -- Iterate through each array element + -- CRITICAL: Only insert records for artists that actually exist in the database + JOIN artist ON artist.id = json_extract(value, '$.artist_id') -- Filter out non-existent artist IDs via INNER JOIN + -- Handle duplicate insertions gracefully (e.g., if called multiple times) + ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates + `, r.tableName) + + _, err = r.executeSQL(Expr(query, itemID, string(participantsJSON))) return err } From 159aa28ec818b59e0bc9d0021df1615de6ef35c2 Mon Sep 17 00:00:00 2001 From: ChekeredList71 <66330496+ChekeredList71@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:00:17 +0000 Subject: [PATCH 20/64] fix(ui): update Hungarian translations (#4375) * Hungarian: new strings and some old ones updated * misplaced keys fixed --------- Co-authored-by: ChekeredList71 <asd@asd.com> --- resources/i18n/hu.json | 78 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index 184f78fe4..a2037eb54 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -12,6 +12,7 @@ "artist": "Előadó", "album": "Album", "path": "Elérési út", + "libraryName": "Könyvtár", "genre": "Műfaj", "compilation": "Válogatásalbum", "year": "Év", @@ -57,6 +58,7 @@ "songCount": "Számok", "playCount": "Lejátszások", "name": "Név", + "libraryName": "Könyvtár", "genre": "Stílus", "compilation": "Válogatásalbum", "year": "Év", @@ -147,19 +149,26 @@ "currentPassword": "Jelenlegi jelszó", "newPassword": "Új jelszó", "token": "Token", - "lastAccessAt": "Utolsó elérés" + "lastAccessAt": "Utolsó elérés", + "libraries": "Könyvtárak" }, "helperTexts": { - "name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg" + "name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg", + "libraries": "Válassz könyvtárakat ehhez a felhasználóhoz vagy ne jelölj be egyet sem, az alapértelmezett könyvtárak használatához" }, "notifications": { "created": "Felhasználó létrehozva", "updated": "Felhasználó frissítve", "deleted": "Felhasználó törölve" }, + "validation": { + "librariesRequired": "Legalább egy könyvtárat ki kell választani nem admin felhasználókhoz" + }, "message": { "listenBrainzToken": "Add meg a ListenBrainz felhasználó tokened.", - "clickHereForToken": "Kattints ide, hogy megszerezd a tokened" + "clickHereForToken": "Kattints ide, hogy megszerezd a tokened", + "selectAllLibraries": "Minden könyvtár kiválasztása", + "adminAutoLibraries": "Minden admin felhasználó hozzáfér bármely könyvtárhoz" } }, "player": { @@ -252,6 +261,7 @@ "fields": { "path": "Útvonal", "size": "Méret", + "libraryName": "Könyvtár", "updatedAt": "Eltűnt ekkor:" }, "actions": { @@ -261,6 +271,58 @@ "notifications": { "removed": "Hiányzó fájl(ok) eltávolítva" } + }, + "library": { + "name": "Könyvtár |||| Könyvtárak", + "fields": { + "name": "Név", + "path": "Elérési út", + "remotePath": "Távoli elérési út", + "lastScanAt": "Legutóbbi szkennelés", + "songCount": "Számok", + "albumCount": "Albumok", + "artistCount": "Előadók", + "totalSongs": "Számok", + "totalAlbums": "Albumok", + "totalArtists": "Előadók", + "totalFolders": "Mappák", + "totalFiles": "Fájlok", + "totalMissingFiles": "Hiányzó fájlok", + "totalSize": "Teljes méret", + "totalDuration": "Hossz", + "defaultNewUsers": "Alapértelmezett könyvtár új felhasználóknak", + "createdAt": "Létrehozva", + "updatedAt": "Frissítve" + }, + "sections": { + "basic": "Alapinformációk", + "statistics": "Statisztikák" + }, + "actions": { + "scan": "Könyvtár szkennelése", + "manageUsers": "Elérés kezelése", + "viewDetails": "Részletek" + }, + "notifications": { + "created": "Könyvtár létrehozva", + "updated": "Könyvtár frissítve", + "deleted": "Könyvtár törölve", + "scanStarted": "Szkennelés folyamatban", + "scanCompleted": "Könyvtár szkennelés befelyezve" + }, + "validation": { + "nameRequired": "Adj meg egy könyvtárnevet", + "pathRequired": "Adj meg egy útvonalat", + "pathNotDirectory": "A könyvtárútvonalnak egy mappának kell lennie", + "pathNotFound": "A könyvtár útvonala nem található", + "pathNotAccessible": "A könyvtár útvonala nem elérhető", + "pathInvalid": "Helytelen könyvtár útvonal" + }, + "messages": { + "deleteConfirm": "Biztosan törlöd ezt a könyvtárt? Minden adata törlődni fog és elérhetetlenné válik.", + "scanInProgress": "Szkennelés folyamatban...", + "noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva" + } } }, "ra": { @@ -448,6 +510,12 @@ }, "menu": { "library": "Könyvtár", + "librarySelector": { + "allLibraries": "Minden %{count} könyvtár", + "multipleLibraries": "%{selected} kiválasztva %{total} könyvtárból", + "selectLibraries": "Kiválasztott kőnyvtárak", + "none": "Semmi" + }, "settings": "Beállítások", "version": "Verzió", "theme": "Téma", @@ -530,8 +598,8 @@ "activity": { "title": "Aktivitás", "totalScanned": "Összes beolvasott mappa:", - "quickScan": "Gyors beolvasás", - "fullScan": "Teljes beolvasás", + "quickScan": "Gyors szkennelés", + "fullScan": "Teljes szkennelés", "serverUptime": "Szerver üzemidő", "serverDown": "OFFLINE", "scanType": "Típus", From 9f0059e13f2019b7f54fe7f8e1661e681d0166fd Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 23 Jul 2025 11:02:07 -0400 Subject: [PATCH 21/64] refactor(tests): clean up tests Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/user_repository_test.go | 81 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 24223857f..7c0707ecd 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" "errors" + "slices" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -19,7 +20,7 @@ var _ = Describe("UserRepository", func() { var repo model.UserRepository BeforeEach(func() { - repo = NewUserRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()) }) Describe("Put/Get/FindByUsername", func() { @@ -80,7 +81,7 @@ var _ = Describe("UserRepository", func() { It("does nothing if passwords are not specified", func() { user := &model.User{ID: "2", UserName: "johndoe"} err := validatePasswordChange(user, loggedUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) }) Context("Autogenerated password (used with Reverse Proxy Authentication)", func() { @@ -92,7 +93,7 @@ var _ = Describe("UserRepository", func() { It("does nothing if passwords are not specified", func() { user = *loggedUser err := validatePasswordChange(&user, loggedUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) }) It("does not requires currentPassword for regular user", func() { user = *loggedUser @@ -119,7 +120,7 @@ var _ = Describe("UserRepository", func() { user := &model.User{ID: "2", UserName: "johndoe"} user.NewPassword = "new" err := validatePasswordChange(user, loggedUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) }) It("requires currentPassword to change its own", func() { user := *loggedUser @@ -157,7 +158,7 @@ var _ = Describe("UserRepository", func() { user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) }) }) @@ -201,10 +202,11 @@ var _ = Describe("UserRepository", func() { user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) }) }) }) + Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User @@ -275,16 +277,16 @@ var _ = Describe("UserRepository", func() { Describe("GetUserLibraries", func() { It("returns empty list when user has no library associations", func() { libraries, err := repo.GetUserLibraries("non-existent-user") - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) It("returns user's associated libraries", func() { err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(userID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) libIDs := []int{libraries[0].ID, libraries[1].ID} @@ -296,24 +298,24 @@ var _ = Describe("UserRepository", func() { It("sets user's library associations", func() { libraryIDs := []int{library1.ID, library2.ID} err := repo.SetUserLibraries(userID, libraryIDs) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(userID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) }) It("replaces existing associations", func() { // Set initial associations err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Replace with just one library err = repo.SetUserLibraries(userID, []int{library1.ID}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(userID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(library1.ID)) }) @@ -321,14 +323,14 @@ var _ = Describe("UserRepository", func() { It("removes all associations when passed empty slice", func() { // Set initial associations err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Remove all err = repo.SetUserLibraries(userID, []int{}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(userID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) }) @@ -347,7 +349,7 @@ var _ = Describe("UserRepository", func() { // Count initial libraries existingLibs, err := libRepo.GetAll() - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) initialLibCount = len(existingLibs) library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"} @@ -377,11 +379,11 @@ var _ = Describe("UserRepository", func() { } err := repo.Put(&adminUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Admin should automatically have access to all libraries (including existing ones) libraries, err := repo.GetUserLibraries(adminUser.ID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries libIDs := make([]int, len(libraries)) @@ -403,20 +405,20 @@ var _ = Describe("UserRepository", func() { } err := repo.Put(®ularUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Give them access to just one library err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID}) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Promote to admin regularUser.IsAdmin = true err = repo.Put(®ularUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Should now have access to all libraries (including existing ones) libraries, err := repo.GetUserLibraries(regularUser.ID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries libIDs := make([]int, len(libraries)) @@ -438,11 +440,11 @@ var _ = Describe("UserRepository", func() { } err := repo.Put(®ularUser) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Regular user should be assigned to default libraries (library ID 1 from migration) libraries, err := repo.GetUserLibraries(regularUser.ID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(1)) Expect(libraries[0].DefaultNewUsers).To(BeTrue()) @@ -492,7 +494,7 @@ var _ = Describe("UserRepository", func() { It("populates Libraries field when getting a single user", func() { user, err := repo.Get(testUser.ID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} @@ -500,10 +502,11 @@ var _ = Describe("UserRepository", func() { // Check that library details are properly populated for _, lib := range user.Libraries { - if lib.ID == library1.ID { + switch lib.ID { + case library1.ID: Expect(lib.Name).To(Equal("Field Test Library 1")) Expect(lib.Path).To(Equal("/field/test/path1")) - } else if lib.ID == library2.ID { + case library2.ID: Expect(lib.Name).To(Equal("Field Test Library 2")) Expect(lib.Path).To(Equal("/field/test/path2")) } @@ -512,17 +515,13 @@ var _ = Describe("UserRepository", func() { It("populates Libraries field when getting all users", func() { users, err := repo.(*userRepository).GetAll() - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) // Find our test user in the results - var foundUser *model.User - for i := range users { - if users[i].ID == testUser.ID { - foundUser = &users[i] - break - } - } + found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID }) + Expect(found).ToNot(Equal(-1)) + foundUser := users[found] Expect(foundUser).ToNot(BeNil()) Expect(foundUser.Libraries).To(HaveLen(2)) @@ -532,7 +531,7 @@ var _ = Describe("UserRepository", func() { It("populates Libraries field when finding user by username", func() { user, err := repo.FindByUsername(testUser.UserName) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} @@ -550,12 +549,10 @@ var _ = Describe("UserRepository", func() { IsAdmin: false, } Expect(repo.Put(&userWithoutLibs)).To(BeNil()) - defer func() { - _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) - }() + defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }() user, err := repo.Get(userWithoutLibs.ID) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).ToNot(BeNil()) // Regular users should be assigned to default libraries (library ID 1 from migration) Expect(user.Libraries).To(HaveLen(1)) From a30fa478acce765504c354ea319ee009d8fa63f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 23 Jul 2025 19:43:42 -0400 Subject: [PATCH 22/64] feat(ui): reset activity panel error icon to normal state when clicked (#4379) * ui: reset activity icon after viewing error * refactor: improve ActivityPanel error acknowledgment logic Replaced boolean errorAcknowledged state with acknowledgedError string state to track which specific error was acknowledged. This prevents icon flickering when error messages change and simplifies the logic by removing the need for useEffect. Key changes: - Changed from errorAcknowledged boolean to acknowledgedError string state - Added derived isErrorVisible computed value for cleaner logic - Removed useEffect dependency on scanStatus.error changes - Updated handleMenuOpen to store actual error string instead of boolean flag - Fixed test mock to return proper error state matching test expectations This change addresses code review feedback and follows React best practices by using derived state instead of imperative effects. --- ui/src/layout/ActivityPanel.jsx | 21 +++++++--- ui/src/layout/ActivityPanel.test.jsx | 61 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 ui/src/layout/ActivityPanel.test.jsx diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx index 6b50cee0c..18af8dc93 100644 --- a/ui/src/layout/ActivityPanel.jsx +++ b/ui/src/layout/ActivityPanel.jsx @@ -75,14 +75,25 @@ const ActivityPanel = () => { scanStatus.scanning, scanStatus.elapsedTime, ) - const classes = useStyles({ up: up && !scanStatus.error }) + const [acknowledgedError, setAcknowledgedError] = useState(null) + const isErrorVisible = + scanStatus.error && scanStatus.error !== acknowledgedError + const classes = useStyles({ + up: up && (!scanStatus.error || !isErrorVisible), + }) const translate = useTranslate() const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) useInitialScanStatus() - const handleMenuOpen = (event) => setAnchorEl(event.currentTarget) + const handleMenuOpen = (event) => { + if (scanStatus.error) { + setAcknowledgedError(scanStatus.error) + } + setAnchorEl(event.currentTarget) + } + const handleMenuClose = () => setAnchorEl(null) const triggerScan = (full) => () => subsonic.startScan({ fullScan: full }) @@ -111,10 +122,10 @@ const ActivityPanel = () => { <div className={classes.wrapper}> <Tooltip title={tooltipTitle}> <IconButton className={classes.button} onClick={handleMenuOpen}> - {!up || scanStatus.error ? ( - <BiError size={'20'} /> + {!up || isErrorVisible ? ( + <BiError data-testid="activity-error-icon" size={'20'} /> ) : ( - <FiActivity size={'20'} /> + <FiActivity data-testid="activity-ok-icon" size={'20'} /> )} </IconButton> </Tooltip> diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx new file mode 100644 index 000000000..c506fd08b --- /dev/null +++ b/ui/src/layout/ActivityPanel.test.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { describe, it, beforeEach } from 'vitest' + +import ActivityPanel from './ActivityPanel' +import { activityReducer } from '../reducers' +import config from '../config' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { + getScanStatus: vi.fn(() => + Promise.resolve({ + json: { + 'subsonic-response': { + status: 'ok', + scanStatus: { error: 'Scan failed' }, + }, + }, + }), + ), + startScan: vi.fn(), + }, +})) + +describe('<ActivityPanel />', () => { + let store + + beforeEach(() => { + store = createStore(combineReducers({ activity: activityReducer }), { + activity: { + scanStatus: { + scanning: false, + folderCount: 0, + count: 0, + error: 'Scan failed', + elapsedTime: 0, + }, + serverStart: { version: config.version, startTime: Date.now() }, + }, + }) + }) + + it('clears the error icon after opening the panel', () => { + render( + <Provider store={store}> + <ActivityPanel /> + </Provider>, + ) + + const button = screen.getByRole('button') + expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument() + + fireEvent.click(button) + + expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument() + expect(screen.getByText('Scan failed')).toBeInTheDocument() + }) +}) From 0da2352907993150ae8a772e9fe145b436c48243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 23 Jul 2025 20:46:47 -0400 Subject: [PATCH 23/64] fix: improve URL path handling in local storage for special characters (#4378) * refactor: improve URL path handling in local storage system Enhanced the local storage implementation to properly handle URL-decoded paths and fix issues with file paths containing special characters. Added decodedPath field to localStorage struct to separate URL parsing concerns from file system operations. Key changes: - Added decodedPath field to localStorage struct for proper URL decoding - Modified newLocalStorage to use decoded path instead of modifying original URL - Fixed Windows path handling to work with decoded paths - Improved URL escaping in storage.For() to handle special characters - Added comprehensive test suite covering URL decoding, symlink resolution, Windows paths, and edge cases - Refactored test extractor to use mockTestExtractor for better isolation This ensures that file paths with spaces, hash symbols, and other special characters are handled correctly throughout the storage system. Signed-off-by: Deluan <deluan@navidrome.org> * fix(tests): fix test file permissions and add missing tests.Init call * refactor(tests): remove redundant test Signed-off-by: Deluan <deluan@navidrome.org> * fix: URL building for Windows and remove redundant variable Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify URL path escaping in local storage Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/storage/local/local_suite_test.go | 6 +- core/storage/local/local_test.go | 428 +++++++++++++++++++++++++ core/storage/storage.go | 11 +- core/storage/storage_test.go | 15 + 4 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 core/storage/local/local_test.go diff --git a/core/storage/local/local_suite_test.go b/core/storage/local/local_suite_test.go index 98dfcbd4b..5934cde5d 100644 --- a/core/storage/local/local_suite_test.go +++ b/core/storage/local/local_suite_test.go @@ -3,11 +3,15 @@ package local import ( "testing" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestLocal(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "Local Storage Test Suite") + RunSpecs(t, "Local Storage Suite") } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go new file mode 100644 index 000000000..3ed01bbc4 --- /dev/null +++ b/core/storage/local/local_test.go @@ -0,0 +1,428 @@ +package local + +import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/model/metadata" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LocalStorage", func() { + var tempDir string + var testExtractor *mockTestExtractor + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + // Create a temporary directory for testing + var err error + tempDir, err = os.MkdirTemp("", "navidrome-local-storage-test-") + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + os.RemoveAll(tempDir) + }) + + // Create and register a test extractor + testExtractor = &mockTestExtractor{ + results: make(map[string]metadata.Info), + } + RegisterExtractor("test", func(fs.FS, string) Extractor { + return testExtractor + }) + conf.Server.Scanner.Extractor = "test" + }) + + Describe("newLocalStorage", func() { + Context("with valid path", func() { + It("should create a localStorage instance with correct path", func() { + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + localStorage := storage.(*localStorage) + + Expect(localStorage.u.Scheme).To(Equal("file")) + // Check that the path is set correctly (could be resolved to real path on macOS) + Expect(localStorage.u.Path).To(ContainSubstring("navidrome-local-storage-test")) + Expect(localStorage.resolvedPath).To(ContainSubstring("navidrome-local-storage-test")) + Expect(localStorage.extractor).ToNot(BeNil()) + }) + + It("should handle URL-decoded paths correctly", func() { + // Create a directory with spaces to test URL decoding + spacedDir := filepath.Join(tempDir, "test folder") + err := os.MkdirAll(spacedDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + // Use proper URL construction instead of manual escaping + u := &url.URL{ + Scheme: "file", + Path: spacedDir, + } + + storage := newLocalStorage(*u) + localStorage, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + + Expect(localStorage.u.Path).To(Equal(spacedDir)) + }) + + It("should resolve symlinks when possible", func() { + // Create a real directory and a symlink to it + realDir := filepath.Join(tempDir, "real") + linkDir := filepath.Join(tempDir, "link") + + err := os.MkdirAll(realDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + err = os.Symlink(realDir, linkDir) + Expect(err).ToNot(HaveOccurred()) + + u, err := url.Parse("file://" + linkDir) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + localStorage, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + + Expect(localStorage.u.Path).To(Equal(linkDir)) + // Check that the resolved path contains the real directory name + Expect(localStorage.resolvedPath).To(ContainSubstring("real")) + }) + + It("should use u.Path as resolvedPath when symlink resolution fails", func() { + // Use a non-existent path to trigger symlink resolution failure + nonExistentPath := filepath.Join(tempDir, "non-existent") + + u, err := url.Parse("file://" + nonExistentPath) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + localStorage, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + + Expect(localStorage.u.Path).To(Equal(nonExistentPath)) + Expect(localStorage.resolvedPath).To(Equal(nonExistentPath)) + }) + }) + + Context("with Windows path", func() { + BeforeEach(func() { + if runtime.GOOS != "windows" { + Skip("Windows-specific test") + } + }) + + It("should handle Windows drive letters correctly", func() { + u, err := url.Parse("file://C:/music") + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + localStorage, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + + Expect(localStorage.u.Path).To(Equal("C:/music")) + }) + }) + + Context("with invalid extractor", func() { + It("should handle extractor validation correctly", func() { + // Note: The actual implementation uses log.Fatal which exits the process, + // so we test the normal path where extractors exist + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + Expect(storage).ToNot(BeNil()) + }) + }) + }) + + Describe("localStorage.FS", func() { + Context("with existing directory", func() { + It("should return a localFS instance", func() { + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + Expect(musicFS).ToNot(BeNil()) + + _, ok := musicFS.(*localFS) + Expect(ok).To(BeTrue()) + }) + }) + + Context("with non-existent directory", func() { + It("should return an error", func() { + nonExistentPath := filepath.Join(tempDir, "non-existent") + u, err := url.Parse("file://" + nonExistentPath) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + _, err = storage.FS() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(nonExistentPath)) + }) + }) + }) + + Describe("localFS.ReadTags", func() { + var testFile string + + BeforeEach(func() { + // Create a test file + testFile = filepath.Join(tempDir, "test.mp3") + err := os.WriteFile(testFile, []byte("test data"), 0600) + Expect(err).ToNot(HaveOccurred()) + + // Reset extractor state + testExtractor.results = make(map[string]metadata.Info) + testExtractor.err = nil + }) + + Context("when extractor returns complete metadata", func() { + It("should return the metadata as-is", func() { + expectedInfo := metadata.Info{ + Tags: map[string][]string{ + "title": {"Test Song"}, + "artist": {"Test Artist"}, + }, + AudioProperties: metadata.AudioProperties{ + Duration: 180, + BitRate: 320, + }, + FileInfo: &testFileInfo{name: "test.mp3"}, + } + + testExtractor.results["test.mp3"] = expectedInfo + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + + results, err := musicFS.ReadTags("test.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveKey("test.mp3")) + Expect(results["test.mp3"]).To(Equal(expectedInfo)) + }) + }) + + Context("when extractor returns metadata without FileInfo", func() { + It("should populate FileInfo from filesystem", func() { + incompleteInfo := metadata.Info{ + Tags: map[string][]string{ + "title": {"Test Song"}, + }, + FileInfo: nil, // Missing FileInfo + } + + testExtractor.results["test.mp3"] = incompleteInfo + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + + results, err := musicFS.ReadTags("test.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveKey("test.mp3")) + + result := results["test.mp3"] + Expect(result.FileInfo).ToNot(BeNil()) + Expect(result.FileInfo.Name()).To(Equal("test.mp3")) + + // Should be wrapped in localFileInfo + _, ok := result.FileInfo.(localFileInfo) + Expect(ok).To(BeTrue()) + }) + }) + + Context("when filesystem stat fails", func() { + It("should return an error", func() { + incompleteInfo := metadata.Info{ + Tags: map[string][]string{"title": {"Test Song"}}, + FileInfo: nil, + } + + testExtractor.results["non-existent.mp3"] = incompleteInfo + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + + _, err = musicFS.ReadTags("non-existent.mp3") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when extractor fails", func() { + It("should return the extractor error", func() { + testExtractor.err = &extractorError{message: "extractor failed"} + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + + _, err = musicFS.ReadTags("test.mp3") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("extractor failed")) + }) + }) + + Context("with multiple files", func() { + It("should process all files correctly", func() { + // Create another test file + testFile2 := filepath.Join(tempDir, "test2.mp3") + err := os.WriteFile(testFile2, []byte("test data 2"), 0600) + Expect(err).ToNot(HaveOccurred()) + + info1 := metadata.Info{ + Tags: map[string][]string{"title": {"Song 1"}}, + FileInfo: &testFileInfo{name: "test.mp3"}, + } + info2 := metadata.Info{ + Tags: map[string][]string{"title": {"Song 2"}}, + FileInfo: nil, // This one needs FileInfo populated + } + + testExtractor.results["test.mp3"] = info1 + testExtractor.results["test2.mp3"] = info2 + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + storage := newLocalStorage(*u) + musicFS, err := storage.FS() + Expect(err).ToNot(HaveOccurred()) + + results, err := musicFS.ReadTags("test.mp3", "test2.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + + Expect(results["test.mp3"].FileInfo).To(Equal(&testFileInfo{name: "test.mp3"})) + Expect(results["test2.mp3"].FileInfo).ToNot(BeNil()) + Expect(results["test2.mp3"].FileInfo.Name()).To(Equal("test2.mp3")) + }) + }) + }) + + Describe("localFileInfo", func() { + var testFile string + var fileInfo fs.FileInfo + + BeforeEach(func() { + testFile = filepath.Join(tempDir, "test.mp3") + err := os.WriteFile(testFile, []byte("test data"), 0600) + Expect(err).ToNot(HaveOccurred()) + + fileInfo, err = os.Stat(testFile) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("BirthTime", func() { + It("should return birth time when available", func() { + lfi := localFileInfo{FileInfo: fileInfo} + birthTime := lfi.BirthTime() + + // Birth time should be a valid time (not zero value) + Expect(birthTime).ToNot(BeZero()) + // Should be around the current time (within last few minutes) + Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) + }) + }) + + It("should delegate all other FileInfo methods", func() { + lfi := localFileInfo{FileInfo: fileInfo} + + Expect(lfi.Name()).To(Equal(fileInfo.Name())) + Expect(lfi.Size()).To(Equal(fileInfo.Size())) + Expect(lfi.Mode()).To(Equal(fileInfo.Mode())) + Expect(lfi.ModTime()).To(Equal(fileInfo.ModTime())) + Expect(lfi.IsDir()).To(Equal(fileInfo.IsDir())) + Expect(lfi.Sys()).To(Equal(fileInfo.Sys())) + }) + }) + + Describe("Storage registration", func() { + It("should register localStorage for file scheme", func() { + // This tests the init() function indirectly + storage, err := storage.For("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) + }) + }) +}) + +// Test extractor for testing +type mockTestExtractor struct { + results map[string]metadata.Info + err error +} + +func (m *mockTestExtractor) Parse(files ...string) (map[string]metadata.Info, error) { + if m.err != nil { + return nil, m.err + } + + result := make(map[string]metadata.Info) + for _, file := range files { + if info, exists := m.results[file]; exists { + result[file] = info + } + } + return result, nil +} + +func (m *mockTestExtractor) Version() string { + return "test-1.0" +} + +type extractorError struct { + message string +} + +func (e *extractorError) Error() string { + return e.message +} + +// Test FileInfo that implements metadata.FileInfo +type testFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool + birthTime time.Time +} + +func (t *testFileInfo) Name() string { return t.name } +func (t *testFileInfo) Size() int64 { return t.size } +func (t *testFileInfo) Mode() fs.FileMode { return t.mode } +func (t *testFileInfo) ModTime() time.Time { return t.modTime } +func (t *testFileInfo) IsDir() bool { return t.isDir } +func (t *testFileInfo) Sys() any { return nil } +func (t *testFileInfo) BirthTime() time.Time { + if t.birthTime.IsZero() { + return time.Now() + } + return t.birthTime +} diff --git a/core/storage/storage.go b/core/storage/storage.go index 84bcae0d6..b9fceb1fd 100644 --- a/core/storage/storage.go +++ b/core/storage/storage.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "sync" + + "github.com/navidrome/navidrome/utils/slice" ) const LocalSchemaID = "file" @@ -36,7 +38,14 @@ func For(uri string) (Storage, error) { if len(parts) < 2 { uri, _ = filepath.Abs(uri) uri = filepath.ToSlash(uri) - uri = LocalSchemaID + "://" + uri + + // Properly escape each path component using URL standards + pathParts := strings.Split(uri, "/") + escapedParts := slice.Map(pathParts, func(s string) string { + return url.PathEscape(s) + }) + + uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/") } u, err := url.Parse(uri) diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index c74c7c6ed..60496e611 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -65,6 +65,21 @@ var _ = Describe("Storage", func() { _, err := For("webdav:///tmp") Expect(err).To(HaveOccurred()) }) + DescribeTable("should handle paths with special characters correctly", + func(inputPath string) { + s, err := For(inputPath) + Expect(err).ToNot(HaveOccurred()) + Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) + Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) + // The path should be exactly the same as the input - after URL parsing it gets decoded back + Expect(s.(*fakeLocalStorage).u.Path).To(Equal(inputPath)) + }, + Entry("hash symbols", "/tmp/test#folder/file.mp3"), + Entry("spaces", "/tmp/test folder/file with spaces.mp3"), + Entry("question marks", "/tmp/test?query/file.mp3"), + Entry("ampersands", "/tmp/test&/file.mp3"), + Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"), + ) }) }) From c8915ecd88607884687b5cd455f614f325bac289 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 24 Jul 2025 17:23:05 -0400 Subject: [PATCH 24/64] fix(server): change sorting from rowid to id for improved sync performance for artists Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/sql_search.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 3aea958cd..d4d068945 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -27,9 +27,9 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in sq = sq.Where(filter) sq = sq.OrderBy(orderBys...) } else { - // If the filter is empty, we sort by rowid. + // If the filter is empty, we sort by id. // This is to speed up the results of `search3?query=""`, for OpenSubsonic - sq = sq.OrderBy(r.tableName + ".rowid") + sq = sq.OrderBy(r.tableName + ".id") } if !includeMissing { sq = sq.Where(Eq{r.tableName + ".missing": false}) From be83d68956f02f695d213fdc51745047da86d0c9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 25 Jul 2025 17:54:51 -0400 Subject: [PATCH 25/64] fix(scanner): fix misleading custom tag split config message. See https://github.com/navidrome/navidrome/discussions/3901#discussioncomment-13883185 Signed-off-by: Deluan <deluan@navidrome.org> --- model/tag_mappings.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model/tag_mappings.go b/model/tag_mappings.go index d54f51f43..0365ed565 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -138,7 +138,7 @@ func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp { escaped = append(escaped, regexp.QuoteMeta(s)) } // If no valid separators remain, return the original value. - if len(escaped) == 0 { + if len(split) > 0 && len(escaped) == 0 { log.Warn("No valid separators found in split list", "split", split, "tag", tagName) return nil } @@ -147,7 +147,7 @@ func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp { pattern := "(?i)(" + strings.Join(escaped, "|") + ")" re, err := regexp.Compile(pattern) if err != nil { - log.Error("Error compiling regexp", "pattern", pattern, "tag", tagName, "err", err) + log.Warn("Error compiling regexp for split list", "pattern", pattern, "tag", tagName, "split", split, err) return nil } return re From eeef98e2caaa4192589f3622e3b4d3e9030dab09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 25 Jul 2025 18:53:40 -0400 Subject: [PATCH 26/64] fix(server): optimize search3 performance with multi-library (#4382) * fix(server): remove includeMissing from search (always false) Signed-off-by: Deluan <deluan@navidrome.org> * fix(search): optimize search order by using natural order for improved performance Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- model/searchable.go | 2 +- persistence/album_repository.go | 6 +-- persistence/artist_repository.go | 7 +-- persistence/artist_repository_test.go | 57 +++++------------------- persistence/mediafile_repository.go | 6 +-- persistence/mediafile_repository_test.go | 24 ++++------ persistence/sql_search.go | 21 +++++---- server/subsonic/searching.go | 4 +- tests/mock_album_repo.go | 2 +- tests/mock_artist_repo.go | 2 +- tests/mock_mediafile_repo.go | 2 +- 11 files changed, 45 insertions(+), 88 deletions(-) diff --git a/model/searchable.go b/model/searchable.go index cc4f0b44e..631a11726 100644 --- a/model/searchable.go +++ b/model/searchable.go @@ -1,5 +1,5 @@ package model type SearchableRepository[T any] interface { - Search(q string, offset, size int, includeMissing bool, options ...QueryOptions) (T, error) + Search(q string, offset, size int, options ...QueryOptions) (T, error) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 682a409a1..6f9bb3b48 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -349,15 +349,15 @@ func (r *albumRepository) purgeEmpty() error { return nil } -func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) { +func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { var res dbAlbums if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res) if err != nil { return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectAlbum(options...), q, offset, size, includeMissing, &res, "name") + err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name") if err != nil { return nil, fmt.Errorf("searching album by query %q: %w", q, err) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index e2e1f83b3..a7cf9272a 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -518,15 +518,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { return totalRowsAffected, nil } -func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) { +func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { var res dbArtists if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res) if err != nil { return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectArtist(options...), q, offset, size, includeMissing, &res, + // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist + err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id", "sum(json_extract(stats, '$.total.m')) desc", "name") if err != nil { return nil, fmt.Errorf("searching artist by query %q: %w", q, err) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 2259012ac..dfaf499ac 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -439,7 +439,7 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Test the search - results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) + results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10) Expect(err).ToNot(HaveOccurred()) if shouldFind { @@ -470,12 +470,12 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // But admin should find it - results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10, false) + results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) @@ -485,40 +485,9 @@ var _ = Describe("ArtistRepository", func() { } }) - It("handles includeMissing parameter for MBID search", func() { - // Create a missing artist with MBID - missingArtist := createTestArtistWithMBID("test-missing-mbid-artist", "Test Missing MBID Artist", "550e8400-e29b-41d4-a716-446655440012") - missingArtist.Missing = true - - err := createArtistWithLibrary(repo, &missingArtist, 1) - Expect(err).ToNot(HaveOccurred()) - - // Mark as missing - if raw, ok := repo.(*artistRepository); ok { - _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) - Expect(err).ToNot(HaveOccurred()) - } - - // Should not find missing artist when includeMissing is false - results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - - // Should find missing artist when includeMissing is true - results, err = repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, true) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) - - // Clean up - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) - } - }) - Context("Text Search", func() { It("allows admin to find artists by name regardless of library", func() { - results, err := repo.Search("Beatles", 0, 10, false) + results, err := repo.Search("Beatles", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("The Beatles")) @@ -538,7 +507,7 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search("Unique Search Name", 0, 10, false) + results, err := restrictedRepo.Search("Unique Search Name", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "Text search should respect library filtering") @@ -639,20 +608,14 @@ var _ = Describe("ArtistRepository", func() { _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) }) - It("admin can see missing artists when explicitly included", func() { + It("missing artists are never returned by search", func() { // Should see missing artist in GetAll by default for admin users artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(3)) // Including the missing artist - // Should see missing artist when searching with includeMissing=true - results, err := repo.Search("Missing Artist", 0, 10, true) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("missing_test")) - - // Should not see missing artist when searching with includeMissing=false - results, err = repo.Search("Missing Artist", 0, 10, false) + // Search never returns missing artists (hardcoded behavior) + results, err := repo.Search("Missing Artist", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -706,11 +669,11 @@ var _ = Describe("ArtistRepository", func() { }) It("Search returns empty results for users without library access", func() { - results, err := restrictedRepo.Search("Beatles", 0, 10, false) + results, err := restrictedRepo.Search("Beatles", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) - results, err = restrictedRepo.Search("Kraftwerk", 0, 10, false) + results, err = restrictedRepo.Search("Kraftwerk", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 7c2ac5778..e7883947a 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -339,15 +339,15 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil return res.toModels(), nil } -func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) { +func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { var res dbMediaFiles if uuid.Validate(q) == nil { - err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) + err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res) if err != nil { return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) } } else { - err := r.doSearch(r.selectMediaFile(options...), q, offset, size, includeMissing, &res, "title") + err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title") if err != nil { return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index b1153b317..002b82499 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -314,7 +314,7 @@ var _ = Describe("MediaRepository", func() { Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { - results, err := mr.Search("Antenna", 0, 10, false) + results, err := mr.Search("Antenna", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 for _, result := range results { @@ -323,7 +323,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media files case insensitively", func() { - results, err := mr.Search("antenna", 0, 10, false) + results, err := mr.Search("antenna", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, result := range results { @@ -332,7 +332,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when no matches found", func() { - results, err := mr.Search("nonexistent", 0, 10, false) + results, err := mr.Search("nonexistent", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -365,7 +365,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_recording_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10, false) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -373,7 +373,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_release_track_id", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10, false) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -381,12 +381,12 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when MBID is not found", func() { - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) - It("handles includeMissing parameter for MBID search", func() { + It("missing media files are never returned by search", func() { // Create a missing media file with MBID missingMediaFile := model.MediaFile{ ID: "test-missing-mbid-mediafile", @@ -400,17 +400,11 @@ var _ = Describe("MediaRepository", func() { err := mr.Put(&missingMediaFile) Expect(err).ToNot(HaveOccurred()) - // Should not find missing media file when includeMissing is false - results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, false) + // Search never returns missing media files (hardcoded behavior) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) - // Should find missing media file when includeMissing is true - results, err = mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, true) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-missing-mbid-mediafile")) - // Clean up _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index d4d068945..0d3bfb743 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -15,7 +15,11 @@ func formatFullText(text ...string) string { return " " + fullText } -func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, includeMissing bool, results any, orderBys ...string) error { +// doSearch performs a full-text search with the specified parameters. +// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like +// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter +// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order. +func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error { q = strings.TrimSpace(q) q = strings.TrimSuffix(q, "*") if len(q) < 2 { @@ -27,23 +31,18 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in sq = sq.Where(filter) sq = sq.OrderBy(orderBys...) } else { - // If the filter is empty, we sort by id. // This is to speed up the results of `search3?query=""`, for OpenSubsonic - sq = sq.OrderBy(r.tableName + ".id") - } - if !includeMissing { - sq = sq.Where(Eq{r.tableName + ".missing": false}) + // If the filter is empty, we sort by the specified natural order. + sq = sq.OrderBy(naturalOrder) } + sq = sq.Where(Eq{r.tableName + ".missing": false}) sq = sq.Limit(uint64(size)).Offset(uint64(offset)) return r.queryAll(sq, results, model.QueryOptions{Offset: offset}) } -func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, includeMissing bool, results any) error { +func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error { sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...)) - - if !includeMissing { - sq = sq.Where(Eq{r.tableName + ".missing": false}) - } + sq = sq.Where(Eq{r.tableName + ".missing": false}) return r.queryAll(sq, results) } diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index e617535ad..ba1071320 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -42,7 +42,7 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { return sp, nil } -type searchFunc[T any] func(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (T, error) +type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error) func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error { return func() error { @@ -52,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(q, offset, size, false, options...) + *result, err = s(q, offset, size, options...) if err != nil { log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) } else { diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 27eba2fbb..642ce6b41 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -118,7 +118,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { return nil } -func (m *MockAlbumRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 1298cbd2a..6d4792f83 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -145,7 +145,7 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } -func (m *MockArtistRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 51c5dd10a..5b38a7187 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -234,7 +234,7 @@ func (m *MockMediaFileRepo) NewInstance() interface{} { return &model.MediaFile{} } -func (m *MockMediaFileRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { if len(options) > 0 { m.Options = options[0] } From 6722af50e2aa58f3c857462af8ac0276c193305c Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Fri, 25 Jul 2025 18:56:52 -0400 Subject: [PATCH 27/64] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 40 +++++++++++++++---------------- go.sum | 76 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/go.mod b/go.mod index e8951570e..e1a827f1d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.24.4 +go 1.24.5 // Fork to fix https://github.com/navidrome/navidrome/pull/3254 replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d @@ -9,7 +9,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/RaveNoX/go-jsoncommentstrip v1.0.0 github.com/andybalholm/cascadia v1.3.3 - github.com/bmatcuk/doublestar/v4 v4.8.1 + github.com/bmatcuk/doublestar/v4 v4.9.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -23,7 +23,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 github.com/go-chi/chi/v5 v5.2.2 - github.com/go-chi/cors v1.2.1 + github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-viper/encoding/ini v0.1.1 @@ -34,17 +34,17 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.0 - github.com/kardianos/service v1.2.2 + github.com/kardianos/service v1.2.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/knqyf263/go-plugin v0.9.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.28 + github.com/mattn/go-sqlite3 v1.14.29 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.37.0 + github.com/onsi/gomega v1.38.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pocketbase/dbx v1.11.0 github.com/pressly/goose/v3 v3.24.3 @@ -58,14 +58,14 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tetratelabs/wazero v1.9.0 github.com/unrolled/secure v1.17.0 - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b - golang.org/x/image v0.28.0 - golang.org/x/net v0.41.0 - golang.org/x/sync v0.15.0 - golang.org/x/sys v0.33.0 - golang.org/x/text v0.26.0 + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 + golang.org/x/image v0.29.0 + golang.org/x/net v0.42.0 + golang.org/x/sync v0.16.0 + golang.org/x/sys v0.34.0 + golang.org/x/text v0.27.0 golang.org/x/time v0.12.0 google.golang.org/protobuf v1.36.6 gopkg.in/yaml.v3 v3.0.1 @@ -84,16 +84,16 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.17.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -120,15 +120,15 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/tools v0.35.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index c6f96bec3..36558f264 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= -github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= +github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= @@ -62,8 +62,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= -github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= @@ -77,8 +77,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= @@ -92,8 +92,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -117,14 +117,14 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= -github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= +github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= +github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI= github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -157,8 +157,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= -github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= +github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -175,8 +175,8 @@ github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= +github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -237,8 +237,9 @@ github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -259,8 +260,8 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -279,21 +280,21 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= -golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= +golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -306,8 +307,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -315,13 +316,12 @@ 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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -335,8 +335,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -357,8 +357,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -369,8 +369,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= From 1eef2e554c34fbf0ff48d8913c78c65d4f586e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 25 Jul 2025 18:58:57 -0400 Subject: [PATCH 28/64] fix(ui): update Danish, German, Greek, Spanish, Finnish, French, Indonesian, Russian, Slovenian, Swedish, Turkish, Ukrainian translations from POEditor (#4326) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/da.json | 1066 +++++++++++++++++++++++----------------- resources/i18n/de.json | 82 +++- resources/i18n/el.json | 82 +++- resources/i18n/es.json | 128 ++++- resources/i18n/fi.json | 138 +++++- resources/i18n/fr.json | 88 +++- resources/i18n/id.json | 102 +++- resources/i18n/ru.json | 84 +++- resources/i18n/sl.json | 1066 +++++++++++++++++++++++----------------- resources/i18n/sv.json | 82 +++- resources/i18n/tr.json | 82 +++- resources/i18n/uk.json | 126 ++++- 12 files changed, 2122 insertions(+), 1004 deletions(-) diff --git a/resources/i18n/da.json b/resources/i18n/da.json index 7d4258d2a..105a20732 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -1,460 +1,628 @@ { - "languageName": "Dansk", - "resources": { - "song": { - "name": "Sang |||| Sange", - "fields": { - "albumArtist": "Album kunstner", - "duration": "Varighed", - "trackNumber": "#", - "playCount": "Afspilninger", - "title": "Titel", - "artist": "Kunstner", - "album": "Album navn", - "path": "Fil placering", - "genre": "Genre", - "compilation": "Samling", - "year": "År", - "size": "Fil størrelse", - "updatedAt": "Opdateret den", - "bitRate": "Bitrate", - "discSubtitle": "Plade undernavn", - "starred": "Stjernemarkeret", - "comment": "Kommentar", - "rating": "", - "quality": "", - "bpm": "", - "playDate": "", - "channels": "", - "createdAt": "" - }, - "actions": { - "addToQueue": "Afspil senere", - "playNow": "Afspil nu", - "addToPlaylist": "Tilføj til afspilningsliste", - "shuffleAll": "Bland alle", - "download": "Hent", - "playNext": "Spil næste", - "info": "" - } - }, - "album": { - "name": "Album |||| Albums", - "fields": { - "albumArtist": "Album kunstner", - "artist": "Kunstner", - "duration": "Varighed", - "songCount": "Sange", - "playCount": "Afspilninger", - "name": "Navn", - "genre": "Genre", - "compilation": "Samling", - "year": "År", - "updatedAt": "Opdateret den", - "comment": "Kommentar", - "rating": "", - "createdAt": "", - "size": "", - "originalDate": "", - "releaseDate": "", - "releases": "", - "released": "" - }, - "actions": { - "playAll": "Afspil", - "playNext": "Afspil næste", - "addToQueue": "Afspil senere", - "shuffle": "Bland", - "addToPlaylist": "Tilføj til afspilningsliste", - "download": "Hent", - "info": "", - "share": "" - }, - "lists": { - "all": "Alle", - "random": "Tilfældig", - "recentlyAdded": "Nyligt tilføjet", - "recentlyPlayed": "Nyligt Afspillet", - "mostPlayed": "Mest Afspillet", - "starred": "Stjernemarkeret", - "topRated": "" - } - }, - "artist": { - "name": "Kunstner |||| Kunstnere", - "fields": { - "name": "Navn", - "albumCount": "Antal album", - "songCount": "Antal sange", - "playCount": "Afspilninger", - "rating": "", - "genre": "", - "size": "" - } - }, - "user": { - "name": "Bruger |||| Brugere", - "fields": { - "userName": "Brugernavn", - "isAdmin": "Er administrator", - "lastLoginAt": "Sidste login", - "updatedAt": "Opdateret den", - "name": "Navn", - "password": "Kodeord", - "createdAt": "Oprettet den", - "changePassword": "", - "currentPassword": "", - "newPassword": "", - "token": "" - }, - "helperTexts": { - "name": "" - }, - "notifications": { - "created": "", - "updated": "", - "deleted": "" - }, - "message": { - "listenBrainzToken": "", - "clickHereForToken": "" - } - }, - "player": { - "name": "Afspiller |||| Afspillere", - "fields": { - "name": "Navn", - "transcodingId": "Omkodning", - "maxBitRate": "Maks. bitrate", - "client": "Klient", - "userName": "Brugernavn", - "lastSeen": "Sidst set", - "reportRealPath": "", - "scrobbleEnabled": "" - } - }, - "transcoding": { - "name": "Omkodning |||| Omkodninger", - "fields": { - "name": "Navn", - "targetFormat": "Målformat", - "defaultBitRate": "Standard bitrate", - "command": "Kommando" - } - }, - "playlist": { - "name": "Afspilningsliste |||| Afspilningslister", - "fields": { - "name": "Navn", - "duration": "Varighed", - "ownerName": "Ejer", - "public": "Offentlig", - "updatedAt": "Opdateret den", - "createdAt": "Oprettet den", - "songCount": "Sange", - "comment": "Kommentar", - "sync": "Auto-importér", - "path": "Importér fra" - }, - "actions": { - "selectPlaylist": "Vælg en afspilningsliste:", - "addNewPlaylist": "Opret \"%{name}\"", - "export": "Eksporter", - "makePublic": "", - "makePrivate": "" - }, - "message": { - "duplicate_song": "", - "song_exist": "" - } - }, - "radio": { - "name": "", - "fields": { - "name": "", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" - }, - "actions": { - "playNow": "" - } - }, - "share": { - "name": "", - "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" - } - } + "languageName": "Dansk", + "resources": { + "song": { + "name": "Sang |||| Sange", + "fields": { + "albumArtist": "Album kunstner", + "duration": "Varighed", + "trackNumber": "#", + "playCount": "Afspilninger", + "title": "Titel", + "artist": "Kunstner", + "album": "Album navn", + "path": "Filsti", + "genre": "Genre", + "compilation": "Opsamling", + "year": "År", + "size": "Fil størrelse", + "updatedAt": "Opdateret den", + "bitRate": "Bitrate", + "discSubtitle": "Plade undertitel", + "starred": "Stjernemarkeret", + "comment": "Kommentar", + "rating": "Bedømmelse", + "quality": "Kvalitet", + "bpm": "BPM", + "playDate": "Senest afspillet", + "channels": "Kanaler", + "createdAt": "Tilføjet d.", + "grouping": "Gruppering", + "mood": "Humør", + "participants": "Yderligere deltagere", + "tags": "Yderligere tags", + "mappedTags": "Mappede tags", + "rawTags": "Rå tags", + "bitDepth": "Bitdybde", + "sampleRate": "Samplingfrekvens", + "missing": "Manglende", + "libraryName": "Bibliotek" + }, + "actions": { + "addToQueue": "Afspil senere", + "playNow": "Afspil nu", + "addToPlaylist": "Føj til afspilningsliste", + "shuffleAll": "Bland alle", + "download": "Download", + "playNext": "Afspil næste", + "info": "Hent info", + "showInPlaylist": "Vis i afspilningsliste" + } }, - "ra": { - "auth": { - "welcome1": "Tak fordi du installerede Navidrome!", - "welcome2": "Opret administrator for at begynde", - "confirmPassword": "Bekræft kodeord", - "buttonCreateAdmin": "Opret administrator", - "auth_check_error": "Venligst login for at fortsætte", - "user_menu": "Profil", - "username": "Brugernavn", - "password": "Password", - "sign_in": "Log ind", - "sign_in_error": "Dit log ind fejlede, prøv igen", - "logout": "Log ud" - }, - "validation": { - "invalidChars": "Vær venlig kun at benytte bogstaver og tal", - "passwordDoesNotMatch": "Kodeord er ikke ens", - "required": "Obligatorisk", - "minLength": "Skal være mindst %{min} tegn", - "maxLength": "Skal være max %{max} tegn", - "minValue": "Skal være mindst %{min}", - "maxValue": "Skal være max %{max}", - "number": "Skal være et nummer", - "email": "Skal være en gyldig e-mail-adresse", - "oneOf": "Skal være en af: %{options}", - "regex": "Skal matche et bestemt format (regexp): %{pattern}", - "unique": "", - "url": "" - }, - "action": { - "add_filter": "Tilføj filter", - "add": "Tilføj", - "back": "Tilbage", - "bulk_actions": "%{smart_count} valgt", - "cancel": "Annuller", - "clear_input_value": "Ryd", - "clone": "Klon", - "confirm": "Bekræft", - "create": "Opret", - "delete": "Slet", - "edit": "Rediger", - "export": "Eksporter", - "list": "Liste", - "refresh": "Opdater", - "remove_filter": "Slet filter", - "remove": "Fjern", - "save": "Gem", - "search": "Søg", - "show": "Vis", - "sort": "Sortér", - "undo": "Fortryd", - "expand": "Udvid", - "close": "Luk", - "open_menu": "Åben menu", - "close_menu": "Luk menu", - "unselect": "Fravælg", - "skip": "", - "bulk_actions_mobile": "", - "share": "", - "download": "" - }, - "boolean": { - "true": "Ja", - "false": "Nej" - }, - "page": { - "create": "Opret %{name}", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Noget gik galt", - "list": "%{name} liste", - "loading": "Henter", - "not_found": "Ikke fundet", - "show": "%{name} #%{id}", - "empty": "Ingen %{name} endnu", - "invite": "Vil du tilføje en?" - }, - "input": { - "file": { - "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.", - "upload_single": "Træk og slip en fil for at uploade, eller klik for at vælge en fil." - }, - "image": { - "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.", - "upload_single": "Træk og slip et billede for at uploade, eller klik for at vælge en fil." - }, - "references": { - "all_missing": "Kan ikke finde nogle referencedata.", - "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.", - "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige." - }, - "password": { - "toggle_visible": "Skjul kodeord", - "toggle_hidden": "Vis kodeord" - } - }, - "message": { - "about": "Om", - "are_you_sure": "Er du sikker?", - "bulk_delete_content": "Er du sikker på du vil slette %{name}? |||| Er du sikker på du ville slette %{smart_count} poster?", - "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster", - "delete_content": "Er du sikker på du ville slette denne post?", - "delete_title": "Slet %{name} #%{id}", - "details": "Detaljer", - "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.", - "invalid_form": "Formularen er ikke gyldig. Kontroller for fejl", - "loading": "Siden indlæses, Vent et øjeblik", - "no": "Nej", - "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et invalidt link.", - "yes": "Ja", - "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?" - }, - "navigation": { - "no_results": "Ingen resultater fundet", - "no_more_results": "Sidenummeret %{page} eksistere ikke. Gå tilbage til forrige side.", - "page_out_of_boundaries": "Sidenummeret %{page} eksistere ikke", - "page_out_from_end": "Der findes ikke flere sider", - "page_out_from_begin": "Der er ingen side før end side 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}", - "page_rows_per_page": "Rækker pr. side:", - "next": "Næste", - "prev": "Forrige", - "skip_nav": "" - }, - "notification": { - "updated": "Objekt opdateret |||| %{smart_count} objekter opdateret", - "created": "Objekt oprettet", - "deleted": "Objekt slettet |||| %{smart_count} objekter slettet", - "bad_item": "Incorrect element", - "item_doesnt_exist": "Objektet findes ikke", - "http_error": "Kommunikationsfejl med serveren", - "data_provider_error": "dataProvider fejl. Check din console for detaljer.", - "i18n_error": "Kan ikke indlæse oversættelse af det ønskede sprog", - "canceled": "Handling blev annulleret", - "logged_out": "Din session er udløbet, venligst tilslut igen", - "new_version": "" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "", - "layout": "", - "grid": "", - "table": "" - } + "album": { + "name": "Album |||| Albums", + "fields": { + "albumArtist": "Album kunstner", + "artist": "Kunstner", + "duration": "Varighed", + "songCount": "Sange", + "playCount": "Afspilninger", + "name": "Navn", + "genre": "Genre", + "compilation": "Opsamling", + "year": "År", + "updatedAt": "Opdateret d.", + "comment": "Kommentar", + "rating": "Bedømmelse", + "createdAt": "Tilføjet d.", + "size": "Størrelse", + "originalDate": "Original", + "releaseDate": "Udgivet", + "releases": "Udgivelse |||| Udgivelser", + "released": "Udgivet", + "recordLabel": "Plademærke", + "catalogNum": "Katalognummer", + "releaseType": "Type", + "grouping": "Gruppering", + "media": "Medier", + "mood": "Humør", + "date": "Optagelsesdato", + "missing": "Manglende", + "libraryName": "Bibliotek" + }, + "actions": { + "playAll": "Afspil", + "playNext": "Afspil næste", + "addToQueue": "Afspil senere", + "shuffle": "Bland", + "addToPlaylist": "Føj til afspilningsliste", + "download": "Download", + "info": "Hent info", + "share": "Del" + }, + "lists": { + "all": "Alle", + "random": "Tilfældig", + "recentlyAdded": "Nyligt tilføjet", + "recentlyPlayed": "Nyligt Afspillet", + "mostPlayed": "Mest Afspillet", + "starred": "Stjernemarkerede", + "topRated": "Top bedømmelse" + } }, - "message": { - "note": "NOTE", - "transcodingDisabled": "Skift af indstillinger for omkodning gennem web platformen er frakoblet af sikkerhedsgrunde. Genstart serveren med %{config} indstilling tilvalgt.", - "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}, hvilket gør det muligt at køre system kommandoer fra web platformen. Vi anbefaler at slå det fra af sikkerhedsgrunde og kun slå det til ved indstilling af omkodning.", - "songsAddedToPlaylist": "Tilføjede 1 sang til afspilningsliste |||| Tilføjede %{smart_count} sange til afspilningsliste", - "noPlaylistsAvailable": "Ingen tilgængelige", - "delete_user_title": "Slet bruger '%{name}'", - "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og indstillinger)?", - "notifications_blocked": "", - "notifications_not_available": "", - "lastfmLinkSuccess": "", - "lastfmLinkFailure": "", - "lastfmUnlinkSuccess": "", - "lastfmUnlinkFailure": "", - "openIn": { - "lastfm": "", - "musicbrainz": "" - }, - "lastfmLink": "", - "listenBrainzLinkSuccess": "", - "listenBrainzLinkFailure": "", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "" + "artist": { + "name": "Kunstner |||| Kunstnere", + "fields": { + "name": "Navn", + "albumCount": "Antal albums", + "songCount": "Antal sange", + "playCount": "Afspilninger", + "rating": "Bedømmelse", + "genre": "Genre", + "size": "Størrelse", + "role": "Rolle", + "missing": "Manglende" + }, + "roles": { + "albumartist": "Albumkunstner |||| Albumkunstnere", + "artist": "Kunstner |||| Kunstnere", + "composer": "Komponist |||| Komponister", + "conductor": "Dirigent |||| Dirigenter", + "lyricist": "Tekstforfatter |||| Tekstforfattere", + "arranger": "Arrangør |||| Arrangører", + "producer": "Producent |||| Producenter", + "director": "Instruktør |||| Instruktører", + "engineer": "Tekniker||||Teknikere", + "mixer": "Mixer |||| Mixere", + "remixer": "Remixer |||| Remixere", + "djmixer": "DJ-mixer |||| DJ-mixere", + "performer": "Udførende kunstner |||| Udførende kunstnere", + "maincredit": "Albumkunstner eller kunstner |||| Albumkunstnere eller kunstnere" + }, + "actions": { + "shuffle": "Bland", + "radio": "Radio", + "topSongs": "Topsange" + } }, - "menu": { - "library": "Bibliotek", - "settings": "Indstillinger", - "version": "Version", - "theme": "Tema", - "personal": { - "name": "Personligt", - "options": { - "theme": "Tema", - "language": "Sprog", - "defaultView": "Standardopsætning", - "desktop_notifications": "", - "lastfmScrobbling": "", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", - "gain": { - "none": "", - "album": "", - "track": "" - } - } - }, - "albumList": "Albums", - "about": "Om", - "playlists": "", - "sharedPlaylists": "" + "user": { + "name": "Bruger |||| Brugere", + "fields": { + "userName": "Brugernavn", + "isAdmin": "Er administrator", + "lastLoginAt": "Seneste login", + "updatedAt": "Opdateret d.", + "name": "Navn", + "password": "Kodeord", + "createdAt": "Oprettet d.", + "changePassword": "Skifte kodeord?", + "currentPassword": "Nuværende kodeord", + "newPassword": "Nyt kodeord", + "token": "Token", + "lastAccessAt": "Senest tilgået", + "libraries": "Biblioteker" + }, + "helperTexts": { + "name": "Ændringer i dit navn vises først ved næste login", + "libraries": "Vælg specifikke biblioteker til denne bruger, eller lad det stå tomt for at bruge standardbiblioteker" + }, + "notifications": { + "created": "Bruger oprettet", + "updated": "Bruger opdateret", + "deleted": "Bruger slettet" + }, + "message": { + "listenBrainzToken": "Skriv dit ListenBrainz token", + "clickHereForToken": "Tryk her for at få dit token", + "selectAllLibraries": "Vælg alle biblioteker", + "adminAutoLibraries": "Administratorbrugere har automatisk adgang til alle biblioteker" + }, + "validation": { + "librariesRequired": "Der skal være valgt mindst ét bibliotek til ikke-administrative brugere" + } }, "player": { - "playListsText": "Afspilnings kø", - "openText": "Åben", - "closeText": "Luk", - "notContentText": "Ingen musik", - "clickToPlayText": "Tryk for at afspille", - "clickToPauseText": "Tryk for at pause", - "nextTrackText": "Næste nummer", - "previousTrackText": "Forrige nummer", - "reloadText": "Genindlæs", - "volumeText": "Lydstyrke", - "toggleLyricText": "Skift sangtekst", - "toggleMiniModeText": "Minimer", - "destroyText": "Fjern", - "downloadText": "Hent", - "removeAudioListsText": "Slet afspillingsliste", - "clickToDeleteText": "Tryk for at slette %{name}", - "emptyLyricText": "Ingen sangtekst", - "playModeText": { - "order": "I rækkefølge", - "orderLoop": "Gentag", - "singleLoop": "Gentag enkelt", - "shufflePlay": "Bland" - } + "name": "Afspiller |||| Afspillere", + "fields": { + "name": "Navn", + "transcodingId": "Transkodning", + "maxBitRate": "Maks. bitrate", + "client": "Klient", + "userName": "Brugernavn", + "lastSeen": "Sidst set", + "reportRealPath": "Vis den virkelige sti", + "scrobbleEnabled": "Send scrobbles til eksterne tjenester" + } }, - "about": { - "links": { - "homepage": "Hjme", - "source": "Kildekode", - "featureRequests": "Ønskede funktioner" - } + "transcoding": { + "name": "Transkodning |||| Transkodninger", + "fields": { + "name": "Navn", + "targetFormat": "Målformat", + "defaultBitRate": "Standard bitrate", + "command": "Kommando" + } }, - "activity": { - "title": "Aktivitet", - "totalScanned": "Antal sange fundet", - "quickScan": "Hurtig søgning", - "fullScan": "Fuld søgning\n", - "serverUptime": "Server uptime", - "serverDown": "OFFLINE" + "playlist": { + "name": "Afspilningsliste |||| Afspilningslister", + "fields": { + "name": "Navn", + "duration": "Varighed", + "ownerName": "Ejer", + "public": "Offentlig", + "updatedAt": "Opdateret d.", + "createdAt": "Oprettet d.", + "songCount": "Sange", + "comment": "Kommentar", + "sync": "Auto-importér", + "path": "Importér fra" + }, + "actions": { + "selectPlaylist": "Vælg en afspilningsliste:", + "addNewPlaylist": "Opret \"%{name}\"", + "export": "Eksportér", + "makePublic": "Offentliggør", + "makePrivate": "Gør privat", + "saveQueue": "Gem kø på afspilningsliste", + "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", + "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", + "removeFromSelection": "Fjern fra valg" + }, + "message": { + "duplicate_song": "Tilføj dubletter af sange", + "song_exist": "Der føjes dubletter til playlisten", + "noPlaylistsFound": "Ingen playlister fundet", + "noPlaylists": "Ingen tilgængelige playlister" + } }, - "help": { - "title": "", - "hotkeys": { - "show_help": "", - "toggle_menu": "", - "toggle_play": "", - "prev_song": "", - "next_song": "", - "vol_up": "", - "vol_down": "", - "toggle_love": "", - "current_song": "" - } + "radio": { + "name": "Radio |||| Radioer", + "fields": { + "name": "Navn", + "streamUrl": "Stream-URL", + "homePageUrl": "Hjemmeside-URL", + "updatedAt": "Opdateret d.", + "createdAt": "Oprettet d." + }, + "actions": { + "playNow": "Afspil nu" + } + }, + "share": { + "name": "Del |||| Delinger", + "fields": { + "username": "Delt af", + "url": "URL", + "description": "Beskrivelse", + "contents": "Indhold", + "expiresAt": "Udløber", + "lastVisitedAt": "Senest besøgt", + "visitCount": "Besøg", + "format": "Format", + "maxBitRate": "Maks. bitrate", + "updatedAt": "Opdateret d.", + "createdAt": "Oprettet d.", + "downloadable": "Tillad downloads?" + } + }, + "missing": { + "name": "Manglende fil |||| Manglende filer", + "fields": { + "path": "Sti", + "size": "Størrelse", + "updatedAt": "Forsvandt d.", + "libraryName": "Bibliotek" + }, + "actions": { + "remove": "Fjern", + "remove_all": "Fjern alle" + }, + "notifications": { + "removed": "Manglende fil(er) fjernet" + }, + "empty": "Ingen manglende filer" + }, + "library": { + "name": "Bibliotek |||| Biblioteker", + "fields": { + "name": "Navn", + "path": "Sti", + "remotePath": "Fjernsti", + "lastScanAt": "Sidste scanning", + "songCount": "Sange", + "albumCount": "Albummer", + "artistCount": "Kunstnere", + "totalSongs": "Sange", + "totalAlbums": "Albummer", + "totalArtists": "Kunstnere", + "totalFolders": "Mapper", + "totalFiles": "Filer", + "totalMissingFiles": "Manglende filer", + "totalSize": "Samlet størrelse", + "totalDuration": "Varighed", + "defaultNewUsers": "Standard for nye brugere", + "createdAt": "Oprettet d.", + "updatedAt": "Opdateret d." + }, + "sections": { + "basic": "Grundlæggende oplysninger", + "statistics": "Statistik" + }, + "actions": { + "scan": "Scanningsbibliotek", + "manageUsers": "Administrer brugeradgang", + "viewDetails": "Se detaljer" + }, + "notifications": { + "created": "Bibliotek oprettet", + "updated": "Biblioteket er blevet opdateret", + "deleted": "Biblioteket er blevet slettet", + "scanStarted": "Biblioteksscanning startet", + "scanCompleted": "Biblioteksscanning fuldført" + }, + "validation": { + "nameRequired": "Biblioteksnavn er påkrævet", + "pathRequired": "Bibliotekssti er påkrævet", + "pathNotDirectory": "Biblioteksstien skal være en mappe", + "pathNotFound": "Biblioteksstien blev ikke fundet", + "pathNotAccessible": "Biblioteksstien er ikke tilgængelig", + "pathInvalid": "Ugyldig bibliotekssti" + }, + "messages": { + "deleteConfirm": "Er du sikker på, at du vil slette dette bibliotek? Dét vil fjerne alle tilknyttede data og brugeradgange", + "scanInProgress": "Scanning i gang...", + "noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger" + } } + }, + "ra": { + "auth": { + "welcome1": "Tak fordi du installerede Navidrome!", + "welcome2": "Først, opret en administrator", + "confirmPassword": "Bekræft kodeord", + "buttonCreateAdmin": "Opret administrator", + "auth_check_error": "Venligst login for at fortsætte", + "user_menu": "Profil", + "username": "Brugernavn", + "password": "Kodeord", + "sign_in": "Log ind", + "sign_in_error": "Dit log ind slog fejl, prøv igen", + "logout": "Log ud", + "insightsCollectionNote": "Navidrome indsamler anonyme brugsdata for at forbedre projektet. Klik [her] for at få mere at vide og fravælge, hvis du ønsker det." + }, + "validation": { + "invalidChars": "Venligst, benyt kun bogstaver og tal", + "passwordDoesNotMatch": "Kodeord er ikke ens", + "required": "Nødvendig", + "minLength": "Skal være mindst %{min} tegn", + "maxLength": "Skal være op til %{max} tegn", + "minValue": "Skal være mindst %{min}", + "maxValue": "Skal være op til %{max}", + "number": "Skal være et tal", + "email": "Skal være en gyldig e-mail-adresse", + "oneOf": "Skal være én af: %{options}", + "regex": "Skal matche et specifikt format (regexp): %{pattern}", + "unique": "Skal være unik", + "url": "Skal være en gyldig URL" + }, + "action": { + "add_filter": "Tilføj filter", + "add": "Tilføj", + "back": "Tilbage", + "bulk_actions": "1 emne valgt |||| %{smart_count} emner valgt", + "cancel": "Annuller", + "clear_input_value": "Ryd", + "clone": "Klon", + "confirm": "Bekræft", + "create": "Opret", + "delete": "Slet", + "edit": "Rediger", + "export": "Eksportér", + "list": "Liste", + "refresh": "Opdater", + "remove_filter": "Slet filter", + "remove": "Fjern", + "save": "Gem", + "search": "Søg", + "show": "Vis", + "sort": "Sortér", + "undo": "Fortryd", + "expand": "Udvid", + "close": "Luk", + "open_menu": "Åbn menu", + "close_menu": "Luk menu", + "unselect": "Fravælg", + "skip": "Spring over", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Del", + "download": "Download" + }, + "boolean": { + "true": "Ja", + "false": "Nej" + }, + "page": { + "create": "Opret %{name}", + "dashboard": "Instrumentbræt", + "edit": "%{name} #%{id}", + "error": "Noget gik galt", + "list": "%{name} liste", + "loading": "Henter", + "not_found": "Ikke fundet", + "show": "%{name} #%{id}", + "empty": "Ingen %{name} endnu.", + "invite": "Vil du tilføje en?" + }, + "input": { + "file": { + "upload_several": "Træk nogle filer herind for at uploade, eller klik for at vælge en.", + "upload_single": "Træk en fil herind for at uploade, eller klik for at vælge den." + }, + "image": { + "upload_several": "Træk billedfiler herind for at uploade, eller klik for at vælge en.", + "upload_single": "Træk en billedfil herind for at uploade, eller klik for at vælge den." + }, + "references": { + "all_missing": "Kan ikke finde nogen referencedata.", + "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.", + "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige." + }, + "password": { + "toggle_visible": "Skjul kodeord", + "toggle_hidden": "Vis kodeord" + } + }, + "message": { + "about": "Om", + "are_you_sure": "Er du sikker?", + "bulk_delete_content": "Er du sikker på, at du vil slette %{name}? |||| Er du sikker på, at du vil slette disse %{smart_count} poster?", + "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster", + "delete_content": "Er du sikker på, at du vil slette denne post?", + "delete_title": "Slet %{name} #%{id}", + "details": "Detaljer", + "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.", + "invalid_form": "Formularen er ikke gyldig. Tjek for fejl", + "loading": "Siden indlæses, vent et øjeblik", + "no": "Nej", + "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et ugyldigt link.", + "yes": "Ja", + "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?" + }, + "navigation": { + "no_results": "Ingen resultater fundet", + "no_more_results": "Sidenummeret %{page} eksisterer ikke. Gå tilbage til forrige side.", + "page_out_of_boundaries": "Sidenummeret %{page} ligger uden for grænserne", + "page_out_from_end": "Dette er sidste side", + "page_out_from_begin": "Dette er side 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}", + "page_rows_per_page": "Rækker pr. side:", + "next": "Næste", + "prev": "Forrige", + "skip_nav": "Hop til indhold" + }, + "notification": { + "updated": "Element opdateret |||| %{smart_count} elementer opdateret", + "created": "Element oprettet", + "deleted": "Element slettet |||| %{smart_count} elementer slettet", + "bad_item": "Forkert element", + "item_doesnt_exist": "Elementet findes ikke", + "http_error": "Kommunikationsfejl med serveren", + "data_provider_error": "dataProvider fejl. Tjek konsollen for detaljer.", + "i18n_error": "Kan ikke indlæse oversættelsen af det ønskede sprog", + "canceled": "Handling blev annulleret", + "logged_out": "Din session er udløbet, venligst tilslut igen", + "new_version": "Ny version tilgængelig! – genopfrisk venligst vinduet" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Antal synlige kolonner", + "layout": "Layout", + "grid": "Gitter", + "table": "Tabel" + } + }, + "message": { + "note": "NOTE", + "transcodingDisabled": "Ændring af indstillinger til transkodning via webgrænsefladen er deaktiveret af sikkerhedshensyn.\nFor at ændre eller tilføje indstillinger skal du genstarte serveren med %{config} konfigurations option.", + "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}. Dét gør det muligt at køre systemkommandoer fra transkodningsindstillingerne, via webgrænsefladen.\nVi anbefaler at deaktivere dette af sikkerhedshensyn og kun have det aktiveret, når du konfigurerer indstillinger til transkodning.", + "songsAddedToPlaylist": "Føjede 1 sang til afspilningsliste |||| Føjede %{smart_count} sange til afspilningsliste", + "noPlaylistsAvailable": "Ingen tilgængelige", + "delete_user_title": "Slet bruger '%{name}'", + "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og valgte indstillinger)?", + "notifications_blocked": "Du blokerer for notifikationer fra dette site i dine browserindstillinger", + "notifications_not_available": "Denne browser understøtter ikke skrivebordsnotifikationer, eller: Du tilgår ikke Navidrome over https", + "lastfmLinkSuccess": "Du er koblet til Last.fm, og scrobbling er slået til", + "lastfmLinkFailure": "Du kan ikke kobles til Last.fm", + "lastfmUnlinkSuccess": "Last.fm frakoblet, og scrobbling deaktiveret", + "lastfmUnlinkFailure": "Last.fm kunne ikke frakobles", + "openIn": { + "lastfm": "Åbn i Last.fm", + "musicbrainz": "Åbn i MusicBrainz" + }, + "lastfmLink": "Læs mere...", + "listenBrainzLinkSuccess": "Du er koblet til ListenBrainz og scrobbling er aktiveret som bruger: %{user}", + "listenBrainzLinkFailure": "Du kunne ikke kobles til ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz er frakoblet, og scrobbling deaktiveret", + "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke frakobles", + "downloadOriginalFormat": "Download i originalformat", + "shareOriginalFormat": "Del i originalformat", + "shareDialogTitle": "Del %{resource} '%{name}'", + "shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}", + "shareSuccess": "URL kopieret til udklipsholder: %{url}", + "shareFailure": "Fejl ved kopiering af URL %{url} til udklipsholder", + "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopiér til udklipsholder: Ctrl+C, Enter", + "remove_missing_title": "Fjern manglende filer", + "remove_missing_content": "Er du sikker på, at du vil fjerne de valgte manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", + "remove_all_missing_title": "Fjern alle manglende filer", + "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", + "noSimilarSongsFound": "Ingen lignende sange fundet", + "noTopSongsFound": "Ingen topsange fundet" + }, + "menu": { + "library": "Bibliotek", + "settings": "Indstillinger", + "version": "Version", + "theme": "Tema", + "personal": { + "name": "Personligt", + "options": { + "theme": "Tema", + "language": "Sprog", + "defaultView": "Standardopsætning", + "desktop_notifications": "Skrivebordsnotifikationer", + "lastfmScrobbling": "Scrobble til Last.fm", + "listenBrainzScrobbling": "Scrobble til ListenBrainz", + "replaygain": "ReplayGain-tilstand", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Slået fra", + "album": "Brug Album Gain", + "track": "Brug Gain for spor" + }, + "lastfmNotConfigured": "Last.fm API-nøglen er ikke konfigureret" + } + }, + "albumList": "Albums", + "about": "Om", + "playlists": "Afspilningslister", + "sharedPlaylists": "Delte afspilningslister", + "librarySelector": { + "allLibraries": "Alle biblioteker (%{count})", + "multipleLibraries": "%{selected} af %{total} biblioteker", + "selectLibraries": "Vælg biblioteker", + "none": "Ingen" + } + }, + "player": { + "playListsText": "Afspilningskø", + "openText": "Åbn", + "closeText": "Luk", + "notContentText": "Ingen musik", + "clickToPlayText": "Tryk for at afspille", + "clickToPauseText": "Tryk for at pause", + "nextTrackText": "Næste nummer", + "previousTrackText": "Forrige nummer", + "reloadText": "Genindlæs", + "volumeText": "Lydstyrke", + "toggleLyricText": "Skift sangtekst til/fra", + "toggleMiniModeText": "Minimer", + "destroyText": "Fjern", + "downloadText": "Hent", + "removeAudioListsText": "Slet afspilningslister", + "clickToDeleteText": "Tryk for at slette %{name}", + "emptyLyricText": "Ingen sangtekst", + "playModeText": { + "order": "I rækkefølge", + "orderLoop": "Gentag", + "singleLoop": "Gentag enkelt", + "shufflePlay": "Bland" + } + }, + "about": { + "links": { + "homepage": "Hjemmeside", + "source": "Kildekode", + "featureRequests": "Funktionsønsker", + "lastInsightsCollection": "Seneste indsamling af indsigter", + "insights": { + "disabled": "Slået fra", + "waiting": "Venter" + } + }, + "tabs": { + "about": "Om", + "config": "Konfiguration" + }, + "config": { + "configName": "Navn på konfiguration", + "environmentVariable": "Miljøvariabel", + "currentValue": "Nuværende værdi", + "configurationFile": "Konfigurationsfil", + "exportToml": "Eksportér konfigurationen (TOML)", + "exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format", + "exportFailed": "Kunne ikke kopiere konfigurationen", + "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", + "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver" + } + }, + "activity": { + "title": "Aktivitet", + "totalScanned": "Antal mapper gennemsøgt", + "quickScan": "Hurtig søgning", + "fullScan": "Fuld søgning", + "serverUptime": "Server oppetid", + "serverDown": "OFFLINE", + "scanType": "Type", + "status": "Scanningsfejl", + "elapsedTime": "Medgået tid" + }, + "help": { + "title": "Navidrome genvejstaster", + "hotkeys": { + "show_help": "Vis denne hjælp", + "toggle_menu": "Skift menu sidepanel", + "toggle_play": "Play / Pause", + "prev_song": "Forrige sang", + "next_song": "Næste sang", + "vol_up": "Volumen op", + "vol_down": "Volumen ned", + "toggle_love": "Føj dette nummer til dine favoritter", + "current_song": "Gå til den aktuelle sang" + } + }, + "nowPlaying": { + "title": "Afspilles nu", + "empty": "Intet afspilles nu", + "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" + } } \ No newline at end of file diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 89e14f296..cd2e47acd 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -35,7 +35,8 @@ "rawTags": "Tag Rohdaten", "bitDepth": "Bittiefe", "sampleRate": "Samplerate", - "missing": "Fehlend" + "missing": "Fehlend", + "libraryName": "Bibliothek" }, "actions": { "addToQueue": "Später abspielen", @@ -76,7 +77,8 @@ "media": "Medium", "mood": "Stimmung", "date": "Aufnahmedatum", - "missing": "Fehlend" + "missing": "Fehlend", + "libraryName": "Bibliothek" }, "actions": { "playAll": "Abspielen", @@ -147,10 +149,12 @@ "currentPassword": "Aktuelles Passwort", "newPassword": "Neues Passwort", "token": "Token", - "lastAccessAt": "Letzter Zugriff am" + "lastAccessAt": "Letzter Zugriff am", + "libraries": "Bibliotheken" }, "helperTexts": { - "name": "Die Änderung wird erst nach dem nächsten Login gültig" + "name": "Die Änderung wird erst nach dem nächsten Login gültig", + "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken" }, "notifications": { "created": "Benutzer erstellt", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Gib deinen ListenBrainz Benutzer Token ein", - "clickHereForToken": "Hier klicken um deinen Token abzurufen" + "clickHereForToken": "Hier klicken um deinen Token abzurufen", + "selectAllLibraries": "Wähle alle Bibliotheken", + "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken" + }, + "validation": { + "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Pfad", "size": "Größe", - "updatedAt": "Fehlt seit" + "updatedAt": "Fehlt seit", + "libraryName": "Bibliothek" }, "actions": { "remove": "Entfernen", @@ -261,6 +271,58 @@ "removed": "Fehlende Datei(en) entfernt" }, "empty": "keine fehlenden Dateien" + }, + "library": { + "name": "Bibliothek ||| Bibliotheken", + "fields": { + "name": "Name", + "path": "Pfad", + "remotePath": "Remote Pfad", + "lastScanAt": "Letzter Scan", + "songCount": "Lieder", + "albumCount": "Alben", + "artistCount": "Interpreten", + "totalSongs": "Lieder", + "totalAlbums": "Alben", + "totalArtists": "Interpreten", + "totalFolders": "Ordner", + "totalFiles": "Dateien", + "totalMissingFiles": "Fehlende Dateien", + "totalSize": "Größe", + "totalDuration": "Dauer", + "defaultNewUsers": "Standard für neue Benutzer", + "createdAt": "Erstellt", + "updatedAt": "Geändert" + }, + "sections": { + "basic": "Basis Informationen", + "statistics": "Statistik" + }, + "actions": { + "scan": "Bibliothek scannen", + "manageUsers": "Zugriff verwalten", + "viewDetails": "Details ansehen" + }, + "notifications": { + "created": "Bibliothek erfolgreich erstellt", + "updated": "Bibliothek erfolgreich geändert", + "deleted": "Bibliothek erfolgreich gelöscht", + "scanStarted": "Bibliothek Scan gestartet", + "scanCompleted": "Bibliothek Scan vollständig" + }, + "validation": { + "nameRequired": "Bibliotheksname ist Pflichtfeld", + "pathRequired": "Bibliothekspfad ist Pflichtfeld", + "pathNotDirectory": "Bibliothekspfad muss ein Ordner sein", + "pathNotFound": "Bibliothekspfad nicht gefunden", + "pathNotAccessible": "Bibliothekspfad nicht zugänglich", + "pathInvalid": "Bibliothekspfad ungültig" + }, + "messages": { + "deleteConfirm": "Möchtest du diese Bibliothek wirklich löschen? Zugriffsrechte und Daten werden entfernt. ", + "scanInProgress": "Bibliothek Scan läuft...", + "noLibrariesAssigned": "Keine Bibliotheken zugeordnet" + } } }, "ra": { @@ -473,7 +535,13 @@ "albumList": "Alben", "about": "Über", "playlists": "Wiedergabelisten", - "sharedPlaylists": "Geteilte Wiedergabelisten" + "sharedPlaylists": "Geteilte Wiedergabelisten", + "librarySelector": { + "allLibraries": "Alle Bibliotheken (%{count})", + "multipleLibraries": "%{selected} von %{total} Bibliotheken", + "selectLibraries": "Bibliotheken auswählen", + "none": "Keine" + } }, "player": { "playListsText": "Warteschlange abspielen", diff --git a/resources/i18n/el.json b/resources/i18n/el.json index d588fa080..0d9ee05c5 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -35,7 +35,8 @@ "rawTags": "Ακατέργαστες ετικέτες", "bitDepth": "Λίγο βάθος", "sampleRate": "Ποσοστό δειγματοληψίας", - "missing": "Απών" + "missing": "Απών", + "libraryName": "Βιβλιοθήκη" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -76,7 +77,8 @@ "media": "Μέσα", "mood": "Διάθεση", "date": "Ημερομηνία Ηχογράφησης", - "missing": "Απών" + "missing": "Απών", + "libraryName": "Βιβλιοθήκη" }, "actions": { "playAll": "Αναπαραγωγή", @@ -147,10 +149,12 @@ "currentPassword": "Υπάρχων Κωδικός Πρόσβασης", "newPassword": "Νέος Κωδικός Πρόσβασης", "token": "Token", - "lastAccessAt": "Τελευταία Πρόσβαση" + "lastAccessAt": "Τελευταία Πρόσβαση", + "libraries": "Βιβλιοθήκες" }, "helperTexts": { - "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση" + "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", + "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη" }, "notifications": { "created": "Ο χρήστης δημιουργήθηκε", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.", - "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας" + "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας", + "selectAllLibraries": "Επιλογή όλων των βιβλιοθηκών", + "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" + }, + "validation": { + "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Διαδρομή", "size": "Μέγεθος", - "updatedAt": "Εξαφανίστηκε" + "updatedAt": "Εξαφανίστηκε", + "libraryName": "Βιβλιοθήκη" }, "actions": { "remove": "Αφαίρεση", @@ -261,6 +271,58 @@ "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": { @@ -473,7 +535,13 @@ "albumList": "Άλμπουμ", "about": "Σχετικά", "playlists": "Λίστες Αναπαραγωγής", - "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής" + "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής", + "librarySelector": { + "allLibraries": "Όλες οι βιβλιοθήκες (%{count})", + "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες", + "selectLibraries": "Επιλέξτε βιβλιοθήκες", + "none": "Κανένα" + } }, "player": { "playListsText": "Ουρά Αναπαραγωγής", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index b640ec115..4c53b8986 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -35,7 +35,8 @@ "rawTags": "Etiquetas sin procesar", "bitDepth": "Profundidad de bits", "sampleRate": "Frecuencia de muestreo", - "missing": "Faltante" + "missing": "Faltante", + "libraryName": "" }, "actions": { "addToQueue": "Reproducir después", @@ -44,7 +45,8 @@ "shuffleAll": "Todas aleatorias", "download": "Descarga", "playNext": "Siguiente", - "info": "Obtener información" + "info": "Obtener información", + "showInPlaylist": "Mostrar en la lista de reproducción" } }, "album": { @@ -75,7 +77,8 @@ "media": "Medios", "mood": "Estado de ánimo", "date": "Fecha de grabación", - "missing": "Faltante" + "missing": "Faltante", + "libraryName": "" }, "actions": { "playAll": "Reproducir", @@ -123,7 +126,13 @@ "mixer": "Mezclador", "remixer": "Remixer", "djmixer": "DJ Mixer", - "performer": "Intérprete" + "performer": "Intérprete", + "maincredit": "" + }, + "actions": { + "shuffle": "Aleatorio", + "radio": "Radio", + "topSongs": "" } }, "user": { @@ -140,10 +149,12 @@ "currentPassword": "Contraseña actual", "newPassword": "Nueva contraseña", "token": "Token", - "lastAccessAt": "Último acceso" + "lastAccessAt": "Último acceso", + "libraries": "" }, "helperTexts": { - "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión" + "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión", + "libraries": "" }, "notifications": { "created": "Usuario creado", @@ -152,7 +163,12 @@ }, "message": { "listenBrainzToken": "Escribe tu token de usuario de ListenBrainz", - "clickHereForToken": "Click aquí para obtener tu token" + "clickHereForToken": "Click aquí para obtener tu token", + "selectAllLibraries": "", + "adminAutoLibraries": "" + }, + "validation": { + "librariesRequired": "" } }, "player": { @@ -197,11 +213,16 @@ "export": "Exportar", "makePublic": "Hazla pública", "makePrivate": "Hazla privada", - "saveQueue": "Guardar la fila de reproducción en una playlist" + "saveQueue": "Guardar la fila de reproducción en una playlist", + "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", + "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", + "removeFromSelection": "Quitar de la selección" }, "message": { "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", - "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?" + "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", + "noPlaylistsFound": "No se encontraron listas de reproducción", + "noPlaylists": "No hay listas de reproducción disponibles" } }, "radio": { @@ -239,7 +260,8 @@ "fields": { "path": "Ruta", "size": "Tamaño", - "updatedAt": "Actualizado el" + "updatedAt": "Actualizado el", + "libraryName": "" }, "actions": { "remove": "Eliminar", @@ -249,6 +271,58 @@ "removed": "Eliminado" }, "empty": "No hay archivos perdidos" + }, + "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": { @@ -430,7 +504,9 @@ "remove_missing_title": "Eliminar elemento faltante", "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "remove_all_missing_title": "Eliminar todos los archivos perdidos", - "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones." + "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", + "noSimilarSongsFound": "No se encontraron canciones similares", + "noTopSongsFound": "" }, "menu": { "library": "Biblioteca", @@ -459,7 +535,13 @@ "albumList": "Álbumes", "about": "Acerca de", "playlists": "Playlists", - "sharedPlaylists": "Playlists Compartidas" + "sharedPlaylists": "Playlists Compartidas", + "librarySelector": { + "allLibraries": "", + "multipleLibraries": "", + "selectLibraries": "", + "none": "" + } }, "player": { "playListsText": "Fila de reproducción", @@ -496,6 +578,21 @@ "disabled": "Deshabilitado", "waiting": "Esperando" } + }, + "tabs": { + "about": "Acerca de", + "config": "Configuración" + }, + "config": { + "configName": "Nombre de la configuración", + "environmentVariable": "Variables de entorno", + "currentValue": "Valor actual", + "configurationFile": "Archivo de configuración", + "exportToml": "Exportar configuración (TOML)", + "exportSuccess": "Configuración exportada al portapapeles en formato TOML", + "exportFailed": "Error al copiar la configuración", + "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)", + "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras" } }, "activity": { @@ -522,5 +619,10 @@ "toggle_love": "Marca esta canción como favorita", "current_song": "Canción actual" } + }, + "nowPlaying": { + "title": "", + "empty": "", + "minutesAgo": "" } -} +} \ No newline at end of file diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 92e43934f..e5ecea2ce 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -35,7 +35,8 @@ "rawTags": "Raakatunnisteet", "bitDepth": "Bittisyvyys", "sampleRate": "Näytteenottotaajuus", - "missing": "" + "missing": "Puuttuva", + "libraryName": "Kirjasto" }, "actions": { "addToQueue": "Lisää jonoon", @@ -44,7 +45,8 @@ "shuffleAll": "Sekoita kaikki", "download": "Lataa", "playNext": "Soita seuraavaksi", - "info": "Info" + "info": "Info", + "showInPlaylist": "Näytä soittolistassa" } }, "album": { @@ -75,7 +77,8 @@ "media": "Media", "mood": "Tunnelma", "date": "Tallennuspäivä", - "missing": "" + "missing": "Puuttuva", + "libraryName": "Kirjasto" }, "actions": { "playAll": "Soita", @@ -108,7 +111,7 @@ "genre": "Tyylilaji", "size": "Koko", "role": "Rooli", - "missing": "" + "missing": "Puuttuva" }, "roles": { "albumartist": "Albumitaiteilija |||| Albumitaiteilijat", @@ -123,7 +126,13 @@ "mixer": "Miksaaja |||| Miksaajat", "remixer": "Remiksaaja |||| Remiksaajat", "djmixer": "DJ-miksaaja |||| DJ-miksaajat", - "performer": "Esiintyjä |||| Esiintyjät" + "performer": "Esiintyjä |||| Esiintyjät", + "maincredit": "Albumin artisti tai artisti |||| Albumin artistit tai artistit" + }, + "actions": { + "shuffle": "Sekoita", + "radio": "Radio", + "topSongs": "Suosituimmat kappaleet" } }, "user": { @@ -140,10 +149,12 @@ "currentPassword": "Nykyinen salasana", "newPassword": "Uusi salasana", "token": "Avain", - "lastAccessAt": "Viimeisin käyttö" + "lastAccessAt": "Viimeisin käyttö", + "libraries": "Kirjastot" }, "helperTexts": { - "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään" + "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään", + "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja" }, "notifications": { "created": "Käyttäjä luotu", @@ -152,7 +163,12 @@ }, "message": { "listenBrainzToken": "Syötä ListenBrainz avain.", - "clickHereForToken": "Paina tästä saadaksesi avaimen" + "clickHereForToken": "Paina tästä saadaksesi avaimen", + "selectAllLibraries": "Valitse kaikki kirjastot", + "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin" + }, + "validation": { + "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille" } }, "player": { @@ -197,11 +213,16 @@ "export": "Vie", "makePublic": "Tee julkinen", "makePrivate": "Tee yksityinen", - "saveQueue": "" + "saveQueue": "Tallenna jono soittolistaan", + "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", + "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", + "removeFromSelection": "Poista valinnasta" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", - "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?" + "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", + "noPlaylistsFound": "Soittolistoja ei löytynyt", + "noPlaylists": "Soittolistoja ei ole saatavilla" } }, "radio": { @@ -239,16 +260,69 @@ "fields": { "path": "Polku", "size": "Koko", - "updatedAt": "Katosi" + "updatedAt": "Katosi", + "libraryName": "Kirjasto" }, "actions": { "remove": "Poista", - "remove_all": "" + "remove_all": "Poista kaikki" }, "notifications": { "removed": "Puuttuvat tiedostot poistettu" }, "empty": "Ei puuttuvia tiedostoja" + }, + "library": { + "name": "Kirjasto |||| Kirjastot", + "fields": { + "name": "Nimi", + "path": "Polku", + "remotePath": "Etäpolku", + "lastScanAt": "Viimeisin skannaus", + "songCount": "Kappaleet", + "albumCount": "Albumit", + "artistCount": "Artistit", + "totalSongs": "Kappaleet", + "totalAlbums": "Albumit", + "totalArtists": "Artistit", + "totalFolders": "Kansiot", + "totalFiles": "Tiedostot", + "totalMissingFiles": "Puuttuvat tiedostot", + "totalSize": "Kokonaiskoko", + "totalDuration": "Kesto", + "defaultNewUsers": "Oletus uusille käyttäjille", + "createdAt": "Luotu", + "updatedAt": "Päivitetty" + }, + "sections": { + "basic": "Perustiedot", + "statistics": "Tilastot" + }, + "actions": { + "scan": "Skannaa kirjasto", + "manageUsers": "Hallitse käyttäjien pääsyä", + "viewDetails": "Näytä tiedot" + }, + "notifications": { + "created": "Kirjasto luotu onnistuneesti", + "updated": "Kirjasto päivitetty onnistuneesti", + "deleted": "Kirjasto poistettu onnistuneesti", + "scanStarted": "Kirjaston skannaus aloitettu", + "scanCompleted": "Kirjaston skannaus valmistunut" + }, + "validation": { + "nameRequired": "Kirjaston nimi vaaditaan", + "pathRequired": "Kirjaston polku vaaditaan", + "pathNotDirectory": "Kirjaston polun tulee olla hakemisto", + "pathNotFound": "Kirjaston polkua ei löytynyt", + "pathNotAccessible": "Kirjaston polku ei ole käytettävissä", + "pathInvalid": "Virheellinen kirjaston polku" + }, + "messages": { + "deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.", + "scanInProgress": "Skannaus käynnissä...", + "noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja" + } } }, "ra": { @@ -429,8 +503,10 @@ "shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter", "remove_missing_title": "Poista puuttuvat tiedostot", "remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.", - "remove_all_missing_title": "", - "remove_all_missing_content": "" + "remove_all_missing_title": "Poista kaikki puuttuvat tiedostot", + "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", + "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", + "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt" }, "menu": { "library": "Kirjasto", @@ -459,7 +535,13 @@ "albumList": "Albumit", "about": "Tietoa", "playlists": "Soittolista", - "sharedPlaylists": "Jaettu soittolista" + "sharedPlaylists": "Jaettu soittolista", + "librarySelector": { + "allLibraries": "Kaikki kirjastot (%{count})", + "multipleLibraries": "%{selected} / %{total} kirjastoa", + "selectLibraries": "Valitse kirjastot", + "none": "Ei mitään" + } }, "player": { "playListsText": "Jono", @@ -496,6 +578,21 @@ "disabled": "Ei käytössä", "waiting": "Odottaa" } + }, + "tabs": { + "about": "Tietoja", + "config": "Kokoonpano" + }, + "config": { + "configName": "Konfiguraation nimi", + "environmentVariable": "Ympäristömuuttuja", + "currentValue": "Nykyinen arvo", + "configurationFile": "Konfiguraatiotiedosto", + "exportToml": "Vie konfiguraatio (TOML)", + "exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa", + "exportFailed": "Konfiguraation kopiointi epäonnistui", + "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", + "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" } }, "activity": { @@ -505,9 +602,9 @@ "fullScan": "Täysi tarkistus", "serverUptime": "Palvelun käyttöaika", "serverDown": "SAMMUTETTU", - "scanType": "", - "status": "", - "elapsedTime": "" + "scanType": "Tyyppi", + "status": "Skannausvirhe", + "elapsedTime": "Kulunut aika" }, "help": { "title": "Navidrome pikapainikkeet", @@ -522,5 +619,10 @@ "toggle_love": "Lisää kappale suosikkeihin", "current_song": "Siirry nykyiseen kappaleeseen" } + }, + "nowPlaying": { + "title": "Nyt soi", + "empty": "Ei soita mitään", + "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } } \ No newline at end of file diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 35bf8987c..af3a8dd31 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -11,7 +11,7 @@ "title": "Titre", "artist": "Artiste", "album": "Album", - "path": "Chemin", + "path": "Chemin d'accès", "genre": "Genre", "compilation": "Compilation", "year": "Année", @@ -35,7 +35,8 @@ "rawTags": "Étiquettes brutes", "bitDepth": "Profondeur de bits", "sampleRate": "Fréquence d'échantillonnage", - "missing": "Manquant" + "missing": "Manquant", + "libraryName": "Bibliothèque" }, "actions": { "addToQueue": "Ajouter à la file", @@ -76,7 +77,8 @@ "media": "Média", "mood": "Humeur", "date": "Date d'enregistrement", - "missing": "Manquant" + "missing": "Manquant", + "libraryName": "Bibliothèque" }, "actions": { "playAll": "Lire", @@ -147,10 +149,12 @@ "currentPassword": "Mot de passe actuel", "newPassword": "Nouveau mot de passe", "token": "Token", - "lastAccessAt": "Dernier accès" + "lastAccessAt": "Dernier accès", + "libraries": "Bibliothèques" }, "helperTexts": { - "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion" + "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion", + "libraries": "Sélectionner une bibliothèque pour cet utilisateur ou laisser vide pour utiliser la bibliothèque par défaut" }, "notifications": { "created": "Utilisateur créé", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Entrez votre token ListenBrainz.", - "clickHereForToken": "Cliquez ici pour recevoir votre token" + "clickHereForToken": "Cliquez ici pour recevoir votre token", + "selectAllLibraries": "Sélectionner toutes les bibliothèques", + "adminAutoLibraries": "Les utilisateurs admin ont automatiquement accès à l'ensemble des bibliothèques" + }, + "validation": { + "librariesRequired": "Au moins une bibliothèque doit être sélectionnée pour les utilisateurs non administrateurs" } }, "player": { @@ -171,7 +180,7 @@ "client": "Client", "userName": "Nom d'utilisateur", "lastSeen": "Vu pour la dernière fois", - "reportRealPath": "Rapporter le chemin absolu", + "reportRealPath": "Rapporter le chemin d'accès absolu", "scrobbleEnabled": "Scrobbler vers des services externes" } }, @@ -249,9 +258,10 @@ "missing": { "name": "Fichier manquant|||| Fichiers manquants", "fields": { - "path": "Chemin", + "path": "Chemin d'accès", "size": "Taille", - "updatedAt": "A disparu le" + "updatedAt": "A disparu le", + "libraryName": "Bibliothèque" }, "actions": { "remove": "Supprimer", @@ -261,6 +271,58 @@ "removed": "Fichier(s) manquant(s) supprimé(s)" }, "empty": "Aucun fichier manquant" + }, + "library": { + "name": "Bibliothèque |||| Bibliothèques", + "fields": { + "name": "Nom", + "path": "Chemin d'accès", + "remotePath": "Chemin d'accès distant", + "lastScanAt": "Dernier scan", + "songCount": "Titres", + "albumCount": "Albums", + "artistCount": "Artistes", + "totalSongs": "Titres", + "totalAlbums": "Albums", + "totalArtists": "Artistes", + "totalFolders": "Dossiers", + "totalFiles": "Fichiers", + "totalMissingFiles": "Fichiers manquants", + "totalSize": "Taille totale", + "totalDuration": "Durée", + "defaultNewUsers": "Défaut pour les nouveaux utilisateurs", + "createdAt": "Crée", + "updatedAt": "Mise à jour" + }, + "sections": { + "basic": "Informations", + "statistics": "Statistiques" + }, + "actions": { + "scan": "Scanner la bibliothèque", + "manageUsers": "Gérer les accès utilisateurs", + "viewDetails": "Voir les détails" + }, + "notifications": { + "created": "Bibliothèque créée avec succès", + "updated": "Bibliothèque mise à jour avec succès", + "deleted": "Bibliothèque supprimée avec succès", + "scanStarted": "Le scan de la bibliothèque a commencé", + "scanCompleted": "Le scan de la bibliothèque est terminé" + }, + "validation": { + "nameRequired": "La bibliothèque doit obligatoirement avoir un nom", + "pathRequired": "La bibliothèque doit obligatoirement avoir un chemin d'accès", + "pathNotDirectory": "Le chemin d'accès de la bibliothèque doit pointer sur un dossier", + "pathNotFound": "Impossible de trouver ce chemin d'accès", + "pathNotAccessible": "Impossible d'accéder à ce chemin d'accès", + "pathInvalid": "Ce chemin d'accès n'est pas valide" + }, + "messages": { + "deleteConfirm": "Êtes-vous sûr(e) de vouloir supprimer cette bibliothèque ? Cela supprimera toutes les données associées ainsi que les accès utilisateurs.", + "scanInProgress": "Scan en cours...", + "noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur" + } } }, "ra": { @@ -473,7 +535,13 @@ "albumList": "Albums", "about": "À propos", "playlists": "Playlists", - "sharedPlaylists": "Playlists partagées" + "sharedPlaylists": "Playlists partagées", + "librarySelector": { + "allLibraries": "Toutes les bibliothèques (%{count})", + "multipleLibraries": "%{selected} bibliothèque(s) sélectionnée(s) sur %{total}", + "selectLibraries": "Sélectionner les bibliothèques", + "none": "Aucune" + } }, "player": { "playListsText": "File de lecture", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index c4bcb72eb..38ee2fff9 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -35,7 +35,8 @@ "rawTags": "Tag raw", "bitDepth": "Bit depth", "sampleRate": "Sample rate", - "missing": "Hilang" + "missing": "Hilang", + "libraryName": "Pustaka" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -76,7 +77,8 @@ "media": "Media", "mood": "Mood", "date": "Tanggal Perekaman", - "missing": "Hilang" + "missing": "Hilang", + "libraryName": "Pustaka" }, "actions": { "playAll": "Putar", @@ -125,12 +127,12 @@ "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| Dj Mixer", "performer": "Performer |||| Performer", - "maincredit": "" + "maincredit": "Artis Album atau Artis |||| Artis Album or Artis" }, "actions": { - "shuffle": "", - "radio": "", - "topSongs": "" + "shuffle": "Acak", + "radio": "Radio", + "topSongs": "Lagu Teratas" } }, "user": { @@ -147,10 +149,12 @@ "currentPassword": "Kata Sandi Sebelumnya", "newPassword": "Kata Sandi Baru", "token": "Token", - "lastAccessAt": "Terakhir Diakses" + "lastAccessAt": "Terakhir Diakses", + "libraries": "Perpustakaan" }, "helperTexts": { - "name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya" + "name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya", + "libraries": "Pilih pustaka yang ditentukan untuk pengguna ini, atau biarkan kosong untuk menggunakan pustaka default" }, "notifications": { "created": "Pengguna dibuat", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.", - "clickHereForToken": "Klik di sini untuk mendapatkan token baru anda" + "clickHereForToken": "Klik di sini untuk mendapatkan token baru anda", + "selectAllLibraries": "Pilih semua pustaka", + "adminAutoLibraries": "Pengguna admin otomatis langsung memiliki akses ke semua perpustakaan" + }, + "validation": { + "librariesRequired": "Setidaknya satu pustaka harus dipilih untuk pengguna non-admin" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Jalur", "size": "Ukuran", - "updatedAt": "Tidak muncul di" + "updatedAt": "Tidak muncul di", + "libraryName": "Pustaka" }, "actions": { "remove": "Hapus", @@ -261,6 +271,58 @@ "removed": "File yang hilang dihapus" }, "empty": "Tidak ada File yang Hilang" + }, + "library": { + "name": "Pustaka |||| Perpustakaan", + "fields": { + "name": "Nama", + "path": "Jalur", + "remotePath": "Jalur Remote", + "lastScanAt": "Terakhir Dipindai", + "songCount": "Lagu", + "albumCount": "Album", + "artistCount": "Artis", + "totalSongs": "Lagu", + "totalAlbums": "Album", + "totalArtists": "Artis", + "totalFolders": "Folder", + "totalFiles": "File", + "totalMissingFiles": "File hilang", + "totalSize": "Ukuran Total", + "totalDuration": "Durasi", + "defaultNewUsers": "Default untuk Pengguna Baru", + "createdAt": "Dibuat", + "updatedAt": "Diperbarui" + }, + "sections": { + "basic": "Informasi Dasar", + "statistics": "Statistik" + }, + "actions": { + "scan": "Pindai Pustaka", + "manageUsers": "Kelola Akses Pengguna", + "viewDetails": "Lihat Detail" + }, + "notifications": { + "created": "Pustaka berhasil dibuat", + "updated": "Pustaka berhasil dibuat", + "deleted": "Berhasil menghapus pustaka", + "scanStarted": "Memindai pustaka dimulai", + "scanCompleted": "Memindai pustaka selesai" + }, + "validation": { + "nameRequired": "Nama pustaka diperlukan", + "pathRequired": "Lokasi pustaka diperlukan", + "pathNotDirectory": "Lokasi pustaka harus ada di direktori", + "pathNotFound": "Lokasi pustaka tidak ditemukan", + "pathNotAccessible": "Lokasi pustaka tidak dapat diakses", + "pathInvalid": "Lokasi pustaka tidak valid" + }, + "messages": { + "deleteConfirm": "Kamu yakin ingin menghapus pustaka ini? Ini akan menghapus semua data yang terkait dan akses pengguna.", + "scanInProgress": "Pemindaian sedang berlangsung...", + "noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini" + } } }, "ra": { @@ -443,8 +505,8 @@ "remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.", "remove_all_missing_title": "Hapus semua file yang hilang", "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", - "noSimilarSongsFound": "", - "noTopSongsFound": "" + "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", + "noTopSongsFound": "Tidak ada lagu teratas ditemukan" }, "menu": { "library": "Pustaka", @@ -473,7 +535,13 @@ "albumList": "Album", "about": "Tentang", "playlists": "Playlist", - "sharedPlaylists": "Playlist yang Dibagikan" + "sharedPlaylists": "Playlist yang Dibagikan", + "librarySelector": { + "allLibraries": "Semua Pustaka (%{count})", + "multipleLibraries": "Pustaka %{selected} dari %{total}", + "selectLibraries": "Pilih Perpustakaan", + "none": "Tidak ada" + } }, "player": { "playListsText": "Putar Antrean", @@ -517,7 +585,7 @@ }, "config": { "configName": "Nama Konfigurasi", - "environmentVariable": "", + "environmentVariable": "Variabel Environment", "currentValue": "Value Saat Ini", "configurationFile": "File Konfigurasi", "exportToml": "Ekspor Konfigurasi (TOML)", @@ -553,8 +621,8 @@ } }, "nowPlaying": { - "title": "", - "empty": "", - "minutesAgo": "" + "title": "Sedang Diputar", + "empty": "Tidak ada yang diputar", + "minutesAgo": "%{smart_count} menit yang lalu |||| %{smart_count} menit yang lalu" } } \ No newline at end of file diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index a2b275693..e29996275 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -35,7 +35,8 @@ "rawTags": "Исходные теги", "bitDepth": "Битовая глубина (Bit)", "sampleRate": "Частота дискретизации (Hz)", - "missing": "Поле отсутствует" + "missing": "Поле отсутствует", + "libraryName": "Библиотека" }, "actions": { "addToQueue": "В очередь", @@ -76,7 +77,8 @@ "media": "Медиа", "mood": "Настроение", "date": "Дата записи", - "missing": "Поле отсутствует" + "missing": "Поле отсутствует", + "libraryName": "Библиотека" }, "actions": { "playAll": "Играть", @@ -125,7 +127,7 @@ "remixer": "Ремиксер |||| Ремиксеры", "djmixer": "DJ-миксер |||| DJ-миксеры", "performer": "Исполнитель |||| Исполнители", - "maincredit": "" + "maincredit": "Исполнитель альбома или Исполнитель |||| Исполнители альбома или Исполнители" }, "actions": { "shuffle": "Смешать", @@ -147,10 +149,12 @@ "currentPassword": "Текущий пароль", "newPassword": "Новый пароль", "token": "Токен", - "lastAccessAt": "Последний доступ" + "lastAccessAt": "Последний доступ", + "libraries": "Библиотеки" }, "helperTexts": { - "name": "Изменение вступит в силу после следующего входа в систему" + "name": "Изменение вступит в силу после следующего входа в систему", + "libraries": "Выберите конкретные библиотеки для этого пользователя или оставьте поле пустым, чтобы использовать библиотеки по умолчанию" }, "notifications": { "created": "Пользователь создан", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Введите свой токен пользователя ListenBrainz.", - "clickHereForToken": "Нажмите здесь, чтобы получить токен" + "clickHereForToken": "Нажмите здесь, чтобы получить токен", + "selectAllLibraries": "Выбрать все библиотеки", + "adminAutoLibraries": "Пользователи-администраторы автоматически получают доступ ко всем библиотекам" + }, + "validation": { + "librariesRequired": "Для пользователей, не являющихся администраторами, должна быть выбрана хотя бы одна библиотека" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Место расположения", "size": "Размер", - "updatedAt": "Исчез" + "updatedAt": "Исчез", + "libraryName": "Библиотека" }, "actions": { "remove": "Удалить", @@ -261,6 +271,58 @@ "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": { @@ -473,7 +535,13 @@ "albumList": "Альбомы", "about": "О нас", "playlists": "Плейлисты", - "sharedPlaylists": "Поделиться плейлистом" + "sharedPlaylists": "Поделиться плейлистом", + "librarySelector": { + "allLibraries": "Все библиотеки (%{count})", + "multipleLibraries": "%{selected} из %{total} Библиотеки", + "selectLibraries": "Выбор библиотек", + "none": "Отсутствует" + } }, "player": { "playListsText": "Очередь Воспроизведения", diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index f860245e8..80bd8e4a3 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -1,460 +1,628 @@ { - "languageName": "Slovenščina", - "resources": { - "song": { - "name": "Pesem |||| Pesmi", - "fields": { - "albumArtist": "Avtor albuma", - "duration": "Dolžina", - "trackNumber": "#", - "playCount": "Predvajano", - "title": "Naslov", - "artist": "Avtor", - "album": "Album", - "path": "Pot datoteke", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Leto", - "size": "Velikost datoteke", - "updatedAt": "Posodobljeno", - "bitRate": "Bitna hitrost", - "discSubtitle": "Podnapisi", - "starred": "Priljubljen", - "comment": "Opomba", - "rating": "Ocena", - "quality": "Kakovost", - "bpm": "BPM", - "playDate": "Zadnja predvajana", - "channels": "Kanali", - "createdAt": "Datum dodano" - }, - "actions": { - "addToQueue": "Predvajaj kasneje", - "playNow": "Predvajaj", - "addToPlaylist": "Dodaj na seznam predvajanj", - "shuffleAll": "Premešaj vse", - "download": "Naloži", - "playNext": "Naslednji", - "info": "Več informacij" - } - }, - "album": { - "name": "Album |||| Albumi", - "fields": { - "albumArtist": "Avtor albuma", - "artist": "Izvajalec", - "duration": "Dolžina", - "songCount": "Pesmi", - "playCount": "Predvajano", - "name": "Naslov", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Leto", - "updatedAt": "Posodobljeno", - "comment": "Opomba", - "rating": "Ocena", - "createdAt": "Datum dodano", - "size": "Velikost", - "originalDate": "Original", - "releaseDate": "Izdano", - "releases": "Izdaja |||| Izdaje", - "released": "Izdano" - }, - "actions": { - "playAll": "Predvajaj vse", - "playNext": "Naslednji", - "addToQueue": "Predvajaj kasneje", - "shuffle": "Premešaj", - "addToPlaylist": "Dodaj v seznam predvajanja", - "download": "Naloži", - "info": "Več informacij", - "share": "Deli" - }, - "lists": { - "all": "Vse", - "random": "Naključno", - "recentlyAdded": "Dodan nedavno", - "recentlyPlayed": "Predvajan nedavno", - "mostPlayed": "Največ predvajano", - "starred": "Priljubljeni", - "topRated": "Najvišje ocenjeno" - } - }, - "artist": { - "name": "Izvajalec |||| Izvajalci", - "fields": { - "name": "Ime", - "albumCount": "# albumov", - "songCount": "# pesmi", - "playCount": "# predvajanj", - "rating": "Ocena", - "genre": "Žanr", - "size": "Velikost" - } - }, - "user": { - "name": "Uporabnik |||| Uporabniki", - "fields": { - "userName": "Uporabnik", - "isAdmin": "Upravitelj", - "lastLoginAt": "Zadnji vpis", - "updatedAt": "Posodobljeno", - "name": "Ime", - "password": "Geslo", - "createdAt": "Ustvarjeno", - "changePassword": "Spremeni geslo?", - "currentPassword": "Trenutno geslo", - "newPassword": "Novo geslo", - "token": "Žeton" - }, - "helperTexts": { - "name": "Sprememba imena bo vidna pri naslednjem vpisu" - }, - "notifications": { - "created": "Uporabnik ustvarjen", - "updated": "Uporabnik posodobljen", - "deleted": "Uporabnik izbrisan" - }, - "message": { - "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.", - "clickHereForToken": "Klikni za žeton" - } - }, - "player": { - "name": "Predvajalnik |||| Predvajalniki", - "fields": { - "name": "Naziv", - "transcodingId": "Transkodiranje", - "maxBitRate": "Maks. bitrate", - "client": "Klijent", - "userName": "Uporabnik", - "lastSeen": "Zadnjič viden", - "reportRealPath": "Zabeleži pravo pot", - "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam" - } - }, - "transcoding": { - "name": "Transkodiranje |||| Transkodiranje", - "fields": { - "name": "Ime", - "targetFormat": "Ciljni format", - "defaultBitRate": "Privzet bitrate", - "command": "Ukaz" - } - }, - "playlist": { - "name": "Seznam predvajanj |||| Seznami predvajanj", - "fields": { - "name": "Ime", - "duration": "Dolžina", - "ownerName": "Lastnik", - "public": "Javno", - "updatedAt": "Posodobljen", - "createdAt": "Ustvarjen", - "songCount": "# pesmi", - "comment": "Opomba", - "sync": "Avtomatski uvoz", - "path": "Uvozi iz" - }, - "actions": { - "selectPlaylist": "Izberi seznam", - "addNewPlaylist": "Ustvari \"%{name}\"", - "export": "Izvozi", - "makePublic": "Naredi javno", - "makePrivate": "Naredi zasebno" - }, - "message": { - "duplicate_song": "Dodaj podvojene pesmi", - "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?" - } - }, - "radio": { - "name": "Radio |||| Radiji", - "fields": { - "name": "Ime", - "streamUrl": "URL toka", - "homePageUrl": "URL domače strani", - "updatedAt": "Posodobljeno ob", - "createdAt": "Ustvarjeno ob" - }, - "actions": { - "playNow": "Predvajaj" - } - }, - "share": { - "name": "Deli |||| Delitev", - "fields": { - "username": "Delil z", - "url": "URL", - "description": "Opis", - "contents": "Vsebine", - "expiresAt": "Poteče", - "lastVisitedAt": "Nazadnje obiskano", - "visitCount": "Obiski", - "format": "Oblika", - "maxBitRate": "Maks. bitna hitrost", - "updatedAt": "Posodobljeno ob", - "createdAt": "Ustvarjeno ob", - "downloadable": "Dovoli prenose?" - } - } + "languageName": "Slovenščina", + "resources": { + "song": { + "name": "Pesem |||| Pesmi", + "fields": { + "albumArtist": "Avtor albuma", + "duration": "Dolžina", + "trackNumber": "#", + "playCount": "Predvajano", + "title": "Naslov", + "artist": "Avtor", + "album": "Album", + "path": "Pot datoteke", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Leto", + "size": "Velikost datoteke", + "updatedAt": "Posodobljeno", + "bitRate": "Bitna hitrost", + "discSubtitle": "Podnapisi", + "starred": "Priljubljen", + "comment": "Opomba", + "rating": "Ocena", + "quality": "Kakovost", + "bpm": "BPM", + "playDate": "Zadnja predvajana", + "channels": "Kanali", + "createdAt": "Datum dodano", + "grouping": "Grupiranje", + "mood": "Razpoloženje", + "participants": "Dodatni udeleženci", + "tags": "Dodatne oznake", + "mappedTags": "Preslikane oznake", + "rawTags": "Nespremenjene oznake", + "bitDepth": "Bitna globina", + "sampleRate": "Frekvenca vzorčenja", + "missing": "Manjka", + "libraryName": "Knjižnica" + }, + "actions": { + "addToQueue": "Predvajaj kasneje", + "playNow": "Predvajaj", + "addToPlaylist": "Dodaj na seznam predvajanj", + "shuffleAll": "Premešaj vse", + "download": "Naloži", + "playNext": "Naslednji", + "info": "Več informacij", + "showInPlaylist": "Prikaži na seznamu predvajanja" + } }, - "ra": { - "auth": { - "welcome1": "Hvala, da ste naložili Navidrome!", - "welcome2": "Za začetek, ustvarite upraviteljski račun", - "confirmPassword": "Potrdi Geslo", - "buttonCreateAdmin": "Ustvari upravitelja", - "auth_check_error": "Vpišite se za nadaljevanje", - "user_menu": "Profil", - "username": "Uporabnik", - "password": "Geslo", - "sign_in": "Vpis", - "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno", - "logout": "Izpis" - }, - "validation": { - "invalidChars": "Uporabi samo alfanumerične znake", - "passwordDoesNotMatch": "Geslo se ne ujema", - "required": "Potreben", - "minLength": "Potrebnih je vsaj %{min} znakov", - "maxLength": "Potrebnih je največ %{max}", - "minValue": "Potrebnih je vsaj %{min}", - "maxValue": "Potrebnih je največ %{max}", - "number": "Mora biti številka", - "email": "Veljaven e-poštni naslov", - "oneOf": "Mora biti ena izmed %{options}", - "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}", - "unique": "Mora biti edinstven", - "url": "Biti mora veljaven URL" - }, - "action": { - "add_filter": "Dodaj filter", - "add": "Dodaj", - "back": "Nazaj", - "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov", - "cancel": "Prekliči", - "clear_input_value": "Pobriši", - "clone": "Podvoji", - "confirm": "Potrdi", - "create": "Ustvari", - "delete": "Izbriši", - "edit": "Uredi", - "export": "Izvozi", - "list": "Seznam", - "refresh": "Osveži", - "remove_filter": "Odstrani filter", - "remove": "Odstrani", - "save": "Shrani", - "search": "Išči", - "show": "Prikaži", - "sort": "Razvrsti", - "undo": "Razveljavi", - "expand": "Razširi", - "close": "Zapri", - "open_menu": "Odpri meni", - "close_menu": "Zapri meni", - "unselect": "Prekliči izbiro", - "skip": "Izpusti", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Deli", - "download": "Prenesi" - }, - "boolean": { - "true": "Da", - "false": "Ne" - }, - "page": { - "create": "Ustvari %{name}", - "dashboard": "Nadzorna plošča", - "edit": "%{name} #%{id}", - "error": "Nedoločena napaka", - "list": "%{name}", - "loading": "Nalagam", - "not_found": "Ni zadetka", - "show": "%{name} #%{id}", - "empty": "Še brez %{name}.", - "invite": "Ga želite dodati?" - }, - "input": { - "file": { - "upload_several": "Povlecite datoteke ali pa kliknite in izberite.", - "upload_single": "Povlecite datoteko ali pa kliknite in izberite." - }, - "image": { - "upload_several": "Povlecite slike, ali pa kliknite in izberite.", - "upload_single": "Povlecite sliko, ali pa kliknite in izberite." - }, - "references": { - "all_missing": "Ne najdem referenciranih podatkov.", - "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.", - "single_missing": "Zdi se, da asociirana referenca ni več na voljo." - }, - "password": { - "toggle_visible": "Skrij geslo", - "toggle_hidden": "Prikaži geslo" - } - }, - "message": { - "about": "O programu", - "are_you_sure": "Ste prepričani?", - "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?", - "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}", - "delete_content": "Ste prepričani, da želite izbrisati ta element?", - "delete_title": "Izbriši %{name} #%{id}", - "details": "Podrobnosti", - "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.", - "invalid_form": "Oblika ni veljavna. Prosim preverite napake", - "loading": "Stran se nalaga, trenutek", - "no": "Ne", - "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.", - "yes": "Da", - "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?" - }, - "navigation": { - "no_results": "Ni zadetkov", - "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.", - "page_out_of_boundaries": "Številka strani %{page} je zunaj meja", - "page_out_from_end": "Ne gre dalje od zadnje strani", - "page_out_from_begin": "Ne gre pred prvo stran", - "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", - "page_rows_per_page": "Elementov na stran:", - "next": "Naslednji", - "prev": "Prejšnji", - "skip_nav": "Preskoči k vsebini" - }, - "notification": { - "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov", - "created": "Element dodan", - "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih", - "bad_item": "Nepravilen element", - "item_doesnt_exist": "Element ne obstaja", - "http_error": "Strežnika napaka v komunikaciji", - "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.", - "i18n_error": "Ne uspem naložiti prevode za izbran jezik", - "canceled": "Akcija preklicana", - "logged_out": "Seja je potekla, prosim povežite se ponovno.", - "new_version": "Na voljo je nova verzija! Prosim osvežite okno." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Prikaži stolpce", - "layout": "Razporeditev", - "grid": "Mreža", - "table": "Tabela" - } + "album": { + "name": "Album |||| Albumi", + "fields": { + "albumArtist": "Avtor albuma", + "artist": "Izvajalec", + "duration": "Dolžina", + "songCount": "Pesmi", + "playCount": "Predvajano", + "name": "Naslov", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Leto", + "updatedAt": "Posodobljeno", + "comment": "Opomba", + "rating": "Ocena", + "createdAt": "Datum dodano", + "size": "Velikost", + "originalDate": "Original", + "releaseDate": "Izdano", + "releases": "Izdaja |||| Izdaje", + "released": "Izdano", + "recordLabel": "Založba", + "catalogNum": "Kataloška številka", + "releaseType": "Tip", + "grouping": "Grupiranje", + "media": "Medij", + "mood": "Razpoloženje", + "date": "Datum snemanja", + "missing": "Manjka", + "libraryName": "Knjižnica" + }, + "actions": { + "playAll": "Predvajaj vse", + "playNext": "Naslednji", + "addToQueue": "Predvajaj kasneje", + "shuffle": "Premešaj", + "addToPlaylist": "Dodaj v seznam predvajanja", + "download": "Naloži", + "info": "Več informacij", + "share": "Deli" + }, + "lists": { + "all": "Vse", + "random": "Naključno", + "recentlyAdded": "Dodan nedavno", + "recentlyPlayed": "Predvajan nedavno", + "mostPlayed": "Največ predvajano", + "starred": "Priljubljeni", + "topRated": "Najvišje ocenjeno" + } }, - "message": { - "note": "OPOMBA", - "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.", - "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.", - "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj", - "noPlaylistsAvailable": "Ni seznamov", - "delete_user_title": "Odstrani uporabnika '%{name}'", - "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?", - "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran", - "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)", - "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen", - "lastfmLinkFailure": "Last.fm ni uspešno povezan", - "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen", - "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena", - "openIn": { - "lastfm": "Odpri v Last.fm", - "musicbrainz": "Odpri v MusicBrainz" - }, - "lastfmLink": "Preberi več...", - "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}", - "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen", - "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna", - "downloadOriginalFormat": "Prenesi v izvirni obliki", - "shareOriginalFormat": "Deli v izvirni obliki", - "shareDialogTitle": "Deli %{resource} '%{name}'", - "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}", - "shareSuccess": "URL kopiran v odložišče: %{url}", - "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče", - "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter" + "artist": { + "name": "Izvajalec |||| Izvajalci", + "fields": { + "name": "Ime", + "albumCount": "# albumov", + "songCount": "# pesmi", + "playCount": "# predvajanj", + "rating": "Ocena", + "genre": "Žanr", + "size": "Velikost", + "role": "Vloga", + "missing": "Manjka" + }, + "roles": { + "albumartist": "Izvajalec albuma |||| Izvajalci albuma", + "artist": "Izvajalec |||| Izvajalci", + "composer": "Skladatelj |||| Skladatelji", + "conductor": "Dirigent |||| Dirigenti", + "lyricist": "Tekstopisec |||| Tekstopisci", + "arranger": "Aranžer |||| Aranžerji", + "producer": "Producent |||| Producenti", + "director": "Glasbeni vodja |||| Glasbene vodje", + "engineer": "Inženir |||| Inženirji", + "mixer": "Mešalec |||| Mešalci", + "remixer": "Remikser |||| Remikserji", + "djmixer": "DJ mešalec |||| DJ mešalci", + "performer": "Izvajalec |||| Izvajalci", + "maincredit": "Izvajalec albuma ali izvajalec |||| Izvajalci albuma ali izvajalci" + }, + "actions": { + "shuffle": "Naključno predvajanje", + "radio": "Radio", + "topSongs": "Najboljše pesmi" + } }, - "menu": { - "library": "Knjižnica", - "settings": "Nastavitve", - "version": "Različica", - "theme": "Tema", - "personal": { - "name": "Osebno", - "options": { - "theme": "Tema", - "language": "Jezik", - "defaultView": "Privzet pogled", - "desktop_notifications": "Namizna obvestila", - "lastfmScrobbling": "'Scrobble' do Last.fm", - "listenBrainzScrobbling": "Scrobble k ListenBrainz", - "replaygain": "ReplayGain način", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Onemogočeno", - "album": "Uporabi Album Gain", - "track": "Uporabi Track Gain" - } - } - }, - "albumList": "Albumi", - "about": "O programu", - "playlists": "Seznami predvajanj", - "sharedPlaylists": "Deljeni seznami predvajanj" + "user": { + "name": "Uporabnik |||| Uporabniki", + "fields": { + "userName": "Uporabnik", + "isAdmin": "Upravitelj", + "lastLoginAt": "Zadnji vpis", + "updatedAt": "Posodobljeno", + "name": "Ime", + "password": "Geslo", + "createdAt": "Ustvarjeno", + "changePassword": "Spremeni geslo?", + "currentPassword": "Trenutno geslo", + "newPassword": "Novo geslo", + "token": "Žeton", + "lastAccessAt": "Zadnji dostop", + "libraries": "Knjižnice" + }, + "helperTexts": { + "name": "Sprememba imena bo vidna pri naslednjem vpisu", + "libraries": "Izberite določene knjižnice za uporabnika ali pustite prazno, če želite uporabiti privzete knjižnice" + }, + "notifications": { + "created": "Uporabnik ustvarjen", + "updated": "Uporabnik posodobljen", + "deleted": "Uporabnik izbrisan" + }, + "message": { + "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.", + "clickHereForToken": "Klikni za žeton", + "selectAllLibraries": "Izberi vse knjižnice", + "adminAutoLibraries": "Skrbniški uporabniki imajo samodejno dostop do vseh knjižnic" + }, + "validation": { + "librariesRequired": "Za uporabnike brez skrbniških pravic mora biti izbrana vsaj ena knjižnica" + } }, "player": { - "playListsText": "Predvajaj vrsto", - "openText": "Odpri", - "closeText": "Zapri", - "notContentText": "Ni glasbe", - "clickToPlayText": "Predvajaj", - "clickToPauseText": "Premor predvajanja", - "nextTrackText": "Naslednje predvajanje", - "previousTrackText": "Prejšnji", - "reloadText": "Ponovno naloži", - "volumeText": "Glasnost", - "toggleLyricText": "Preklopi besedila", - "toggleMiniModeText": "Pomanjšaj", - "destroyText": "Uniči", - "downloadText": "Naloži", - "removeAudioListsText": "Izbriši avdio seznam", - "clickToDeleteText": "Klikni za izbris %{name}", - "emptyLyricText": "Ni besedila", - "playModeText": { - "order": "Po vrsti", - "orderLoop": "Ponavljaj", - "singleLoop": "Ponovi enkrat", - "shufflePlay": "Premešaj" - } + "name": "Predvajalnik |||| Predvajalniki", + "fields": { + "name": "Naziv", + "transcodingId": "Transkodiranje", + "maxBitRate": "Maks. bitrate", + "client": "Klijent", + "userName": "Uporabnik", + "lastSeen": "Zadnjič viden", + "reportRealPath": "Zabeleži pravo pot", + "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam" + } }, - "about": { - "links": { - "homepage": "Domača stran", - "source": "Izvorna koda", - "featureRequests": "Funkcionalni zahtevki" - } + "transcoding": { + "name": "Transkodiranje |||| Transkodiranje", + "fields": { + "name": "Ime", + "targetFormat": "Ciljni format", + "defaultBitRate": "Privzet bitrate", + "command": "Ukaz" + } }, - "activity": { - "title": "Aktivnost", - "totalScanned": "Skupaj preiskanih map", - "quickScan": "Hitro preišči", - "fullScan": "Polno preišči", - "serverUptime": "Čas delovanja", - "serverDown": "NEPOVEZAN" + "playlist": { + "name": "Seznam predvajanj |||| Seznami predvajanj", + "fields": { + "name": "Ime", + "duration": "Dolžina", + "ownerName": "Lastnik", + "public": "Javno", + "updatedAt": "Posodobljen", + "createdAt": "Ustvarjen", + "songCount": "# pesmi", + "comment": "Opomba", + "sync": "Avtomatski uvoz", + "path": "Uvozi iz" + }, + "actions": { + "selectPlaylist": "Izberi seznam", + "addNewPlaylist": "Ustvari \"%{name}\"", + "export": "Izvozi", + "makePublic": "Naredi javno", + "makePrivate": "Naredi zasebno", + "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", + "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", + "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", + "removeFromSelection": "Odstrani iz izbora" + }, + "message": { + "duplicate_song": "Dodaj podvojene pesmi", + "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", + "noPlaylistsFound": "Ni najdenih seznamov predvajanja", + "noPlaylists": "Ni na voljo seznamov predvajanja" + } }, - "help": { - "title": "Hitre tipke", - "hotkeys": { - "show_help": "Prikaži pomoč", - "toggle_menu": "Preklopi stransko vrstico menija", - "toggle_play": "Predvajaj / Pavza", - "prev_song": "Prejšnja", - "next_song": "Naslednja", - "vol_up": "Zvišaj glasnost", - "vol_down": "Znižaj glasnost", - "toggle_love": "Dodaj med priljubljene", - "current_song": "Skoči na predvajano" - } + "radio": { + "name": "Radio |||| Radiji", + "fields": { + "name": "Ime", + "streamUrl": "URL toka", + "homePageUrl": "URL domače strani", + "updatedAt": "Posodobljeno ob", + "createdAt": "Ustvarjeno ob" + }, + "actions": { + "playNow": "Predvajaj" + } + }, + "share": { + "name": "Deli |||| Delitev", + "fields": { + "username": "Delil z", + "url": "URL", + "description": "Opis", + "contents": "Vsebine", + "expiresAt": "Poteče", + "lastVisitedAt": "Nazadnje obiskano", + "visitCount": "Obiski", + "format": "Oblika", + "maxBitRate": "Maks. bitna hitrost", + "updatedAt": "Posodobljeno ob", + "createdAt": "Ustvarjeno ob", + "downloadable": "Dovoli prenose?" + } + }, + "missing": { + "name": "Manjkajoča datoteka |||| Manjkajoče datoteke", + "fields": { + "path": "Pot", + "size": "Velikost", + "updatedAt": "Izginil", + "libraryName": "Knjižnica" + }, + "actions": { + "remove": "Odstrani", + "remove_all": "Odstrani vse" + }, + "notifications": { + "removed": "Manjkajoče datoteke odstranjene" + }, + "empty": "Brez manjkajočih datotek" + }, + "library": { + "name": "Knjižnica |||| Knjižnice", + "fields": { + "name": "Ime", + "path": "Pot", + "remotePath": "Oddaljena pot", + "lastScanAt": "Zadnje skeniranje", + "songCount": "Pesmi", + "albumCount": "Albumi", + "artistCount": "Umetniki", + "totalSongs": "Pesmi", + "totalAlbums": "Albumi", + "totalArtists": "Umetniki", + "totalFolders": "Mape", + "totalFiles": "Datoteke", + "totalMissingFiles": "Manjkajoče datoteke", + "totalSize": "Skupna velikost", + "totalDuration": "Trajanje", + "defaultNewUsers": "Privzeto za nove uporabnike", + "createdAt": "Ustvarjeno", + "updatedAt": "Posodobljeno" + }, + "sections": { + "basic": "Osnovne informacije", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skeniraj knjižnico", + "manageUsers": "Upravljanje dostopa uporabnikov", + "viewDetails": "Ogled podrobnosti" + }, + "notifications": { + "created": "Knjižnica je uspešno ustvarjena", + "updated": "Knjižnica je bila uspešno posodobljena", + "deleted": "Knjižnica je uspešno izbrisana", + "scanStarted": "Skeniranje knjižnice se je začelo", + "scanCompleted": "Skeniranje knjižnice končano" + }, + "validation": { + "nameRequired": "Ime knjižnice je obvezno", + "pathRequired": "Pot do knjižnice je obvezna", + "pathNotDirectory": "Pot do knjižnice mora biti imenik", + "pathNotFound": "Pot do knjižnice ni bila najdena", + "pathNotAccessible": "Pot do knjižnice ni dostopna", + "pathInvalid": "Neveljavna pot do knjižnice" + }, + "messages": { + "deleteConfirm": "Ali ste prepričani, da želite izbrisati to knjižnico? S tem boste odstranili vse povezane podatke in dostop uporabnikov.", + "scanInProgress": "Skeniranje v teku...", + "noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic" + } } + }, + "ra": { + "auth": { + "welcome1": "Hvala, da ste naložili Navidrome!", + "welcome2": "Za začetek, ustvarite upraviteljski račun", + "confirmPassword": "Potrdi Geslo", + "buttonCreateAdmin": "Ustvari upravitelja", + "auth_check_error": "Vpišite se za nadaljevanje", + "user_menu": "Profil", + "username": "Uporabnik", + "password": "Geslo", + "sign_in": "Vpis", + "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno", + "logout": "Izpis", + "insightsCollectionNote": "Navidrome zbira anonimne podatke o uporabi \nz namenom izboljšanja projekta. \nKliknite [tukaj], če želite izvedeti več ali se odjaviti" + }, + "validation": { + "invalidChars": "Uporabi samo alfanumerične znake", + "passwordDoesNotMatch": "Geslo se ne ujema", + "required": "Potreben", + "minLength": "Potrebnih je vsaj %{min} znakov", + "maxLength": "Potrebnih je največ %{max}", + "minValue": "Potrebnih je vsaj %{min}", + "maxValue": "Potrebnih je največ %{max}", + "number": "Mora biti številka", + "email": "Veljaven e-poštni naslov", + "oneOf": "Mora biti ena izmed %{options}", + "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}", + "unique": "Mora biti edinstven", + "url": "Biti mora veljaven URL" + }, + "action": { + "add_filter": "Dodaj filter", + "add": "Dodaj", + "back": "Nazaj", + "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov", + "cancel": "Prekliči", + "clear_input_value": "Pobriši", + "clone": "Podvoji", + "confirm": "Potrdi", + "create": "Ustvari", + "delete": "Izbriši", + "edit": "Uredi", + "export": "Izvozi", + "list": "Seznam", + "refresh": "Osveži", + "remove_filter": "Odstrani filter", + "remove": "Odstrani", + "save": "Shrani", + "search": "Išči", + "show": "Prikaži", + "sort": "Razvrsti", + "undo": "Razveljavi", + "expand": "Razširi", + "close": "Zapri", + "open_menu": "Odpri meni", + "close_menu": "Zapri meni", + "unselect": "Prekliči izbiro", + "skip": "Izpusti", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Deli", + "download": "Prenesi" + }, + "boolean": { + "true": "Da", + "false": "Ne" + }, + "page": { + "create": "Ustvari %{name}", + "dashboard": "Nadzorna plošča", + "edit": "%{name} #%{id}", + "error": "Nedoločena napaka", + "list": "%{name}", + "loading": "Nalagam", + "not_found": "Ni zadetka", + "show": "%{name} #%{id}", + "empty": "Še brez %{name}.", + "invite": "Ga želite dodati?" + }, + "input": { + "file": { + "upload_several": "Povlecite datoteke ali pa kliknite in izberite.", + "upload_single": "Povlecite datoteko ali pa kliknite in izberite." + }, + "image": { + "upload_several": "Povlecite slike, ali pa kliknite in izberite.", + "upload_single": "Povlecite sliko, ali pa kliknite in izberite." + }, + "references": { + "all_missing": "Ne najdem referenciranih podatkov.", + "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.", + "single_missing": "Zdi se, da asociirana referenca ni več na voljo." + }, + "password": { + "toggle_visible": "Skrij geslo", + "toggle_hidden": "Prikaži geslo" + } + }, + "message": { + "about": "O programu", + "are_you_sure": "Ste prepričani?", + "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?", + "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}", + "delete_content": "Ste prepričani, da želite izbrisati ta element?", + "delete_title": "Izbriši %{name} #%{id}", + "details": "Podrobnosti", + "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.", + "invalid_form": "Oblika ni veljavna. Prosim preverite napake", + "loading": "Stran se nalaga, trenutek", + "no": "Ne", + "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.", + "yes": "Da", + "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?" + }, + "navigation": { + "no_results": "Ni zadetkov", + "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.", + "page_out_of_boundaries": "Številka strani %{page} je zunaj meja", + "page_out_from_end": "Ne gre dalje od zadnje strani", + "page_out_from_begin": "Ne gre pred prvo stran", + "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", + "page_rows_per_page": "Elementov na stran:", + "next": "Naslednji", + "prev": "Prejšnji", + "skip_nav": "Preskoči k vsebini" + }, + "notification": { + "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov", + "created": "Element dodan", + "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih", + "bad_item": "Nepravilen element", + "item_doesnt_exist": "Element ne obstaja", + "http_error": "Strežnika napaka v komunikaciji", + "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.", + "i18n_error": "Ne uspem naložiti prevode za izbran jezik", + "canceled": "Akcija preklicana", + "logged_out": "Seja je potekla, prosim povežite se ponovno.", + "new_version": "Na voljo je nova verzija! Prosim osvežite okno." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Prikaži stolpce", + "layout": "Razporeditev", + "grid": "Mreža", + "table": "Tabela" + } + }, + "message": { + "note": "OPOMBA", + "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.", + "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.", + "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj", + "noPlaylistsAvailable": "Ni seznamov", + "delete_user_title": "Odstrani uporabnika '%{name}'", + "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?", + "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran", + "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)", + "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen", + "lastfmLinkFailure": "Last.fm ni uspešno povezan", + "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen", + "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena", + "openIn": { + "lastfm": "Odpri v Last.fm", + "musicbrainz": "Odpri v MusicBrainz" + }, + "lastfmLink": "Preberi več...", + "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}", + "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen", + "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna", + "downloadOriginalFormat": "Prenesi v izvirni obliki", + "shareOriginalFormat": "Deli v izvirni obliki", + "shareDialogTitle": "Deli %{resource} '%{name}'", + "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}", + "shareSuccess": "URL kopiran v odložišče: %{url}", + "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče", + "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter", + "remove_missing_title": "Odstrani manjkajoče datoteke", + "remove_missing_content": "Ste prepričani, da želite odstraniti izbrane manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", + "remove_all_missing_title": "Odstrani vse manjkajoče datoteke", + "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", + "noSimilarSongsFound": "Ni najdenih podobnih pesmi", + "noTopSongsFound": "Ni najdenih najboljših pesmi" + }, + "menu": { + "library": "Knjižnica", + "settings": "Nastavitve", + "version": "Različica", + "theme": "Tema", + "personal": { + "name": "Osebno", + "options": { + "theme": "Tema", + "language": "Jezik", + "defaultView": "Privzet pogled", + "desktop_notifications": "Namizna obvestila", + "lastfmScrobbling": "'Scrobble' do Last.fm", + "listenBrainzScrobbling": "Scrobble k ListenBrainz", + "replaygain": "ReplayGain način", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Onemogočeno", + "album": "Uporabi Album Gain", + "track": "Uporabi Track Gain" + }, + "lastfmNotConfigured": "Last.fm API ključ ni konfiguriran" + } + }, + "albumList": "Albumi", + "about": "O programu", + "playlists": "Seznami predvajanj", + "sharedPlaylists": "Deljeni seznami predvajanj", + "librarySelector": { + "allLibraries": "Vse knjižnice (%{count})", + "multipleLibraries": "%{selected} od %{total} knjižnic", + "selectLibraries": "Izberite knjižnice", + "none": "Nobena" + } + }, + "player": { + "playListsText": "Predvajaj vrsto", + "openText": "Odpri", + "closeText": "Zapri", + "notContentText": "Ni glasbe", + "clickToPlayText": "Predvajaj", + "clickToPauseText": "Premor predvajanja", + "nextTrackText": "Naslednje predvajanje", + "previousTrackText": "Prejšnji", + "reloadText": "Ponovno naloži", + "volumeText": "Glasnost", + "toggleLyricText": "Preklopi besedila", + "toggleMiniModeText": "Pomanjšaj", + "destroyText": "Uniči", + "downloadText": "Naloži", + "removeAudioListsText": "Izbriši avdio seznam", + "clickToDeleteText": "Klikni za izbris %{name}", + "emptyLyricText": "Ni besedila", + "playModeText": { + "order": "Po vrsti", + "orderLoop": "Ponavljaj", + "singleLoop": "Ponovi enkrat", + "shufflePlay": "Premešaj" + } + }, + "about": { + "links": { + "homepage": "Domača stran", + "source": "Izvorna koda", + "featureRequests": "Funkcionalni zahtevki", + "lastInsightsCollection": "Zbirka zadnjih vpogledov", + "insights": { + "disabled": "Onemogočeno", + "waiting": "Čakanje" + } + }, + "tabs": { + "about": "O nas", + "config": "Konfiguracija" + }, + "config": { + "configName": "Ime konfiguracije", + "environmentVariable": "Spremenljivka okolja", + "currentValue": "Trenutna vrednost", + "configurationFile": "Konfiguracijska datoteka", + "exportToml": "Izvozi konfiguracijo (TOML)", + "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML", + "exportFailed": "Kopiranje konfiguracije ni uspelo", + "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)", + "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah" + } + }, + "activity": { + "title": "Aktivnost", + "totalScanned": "Skupaj preiskanih map", + "quickScan": "Hitro preišči", + "fullScan": "Polno preišči", + "serverUptime": "Čas delovanja", + "serverDown": "NEPOVEZAN", + "scanType": "Tip", + "status": "Napaka pri skeniranju", + "elapsedTime": "Pretečeni čas" + }, + "help": { + "title": "Hitre tipke", + "hotkeys": { + "show_help": "Prikaži pomoč", + "toggle_menu": "Preklopi stransko vrstico menija", + "toggle_play": "Predvajaj / Pavza", + "prev_song": "Prejšnja", + "next_song": "Naslednja", + "vol_up": "Zvišaj glasnost", + "vol_down": "Znižaj glasnost", + "toggle_love": "Dodaj med priljubljene", + "current_song": "Skoči na predvajano" + } + }, + "nowPlaying": { + "title": "Zdaj se predvaja", + "empty": "Nič se ne predvaja", + "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami" + } } \ No newline at end of file diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 915a56121..521f997a8 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -35,7 +35,8 @@ "rawTags": "Omodifierade taggar", "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", - "missing": "Saknade" + "missing": "Saknade", + "libraryName": "Bibliotek" }, "actions": { "addToQueue": "Lägg till i kön", @@ -76,7 +77,8 @@ "media": "Media", "mood": "Stämning", "date": "Inspelningsdatum", - "missing": "Saknade" + "missing": "Saknade", + "libraryName": "Bibliotek" }, "actions": { "playAll": "Spela", @@ -147,10 +149,12 @@ "currentPassword": "Nuvarande lösenord", "newPassword": "Nytt lösenord", "token": "Token", - "lastAccessAt": "Senaste åtkomst" + "lastAccessAt": "Senaste åtkomst", + "libraries": "Bibliotek" }, "helperTexts": { - "name": "Ändringar av ditt namn syns först vid nästa inloggning" + "name": "Ändringar av ditt namn syns först vid nästa inloggning", + "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" }, "notifications": { "created": "Användare skapad", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "Ange din ListenBrainz användar-token.", - "clickHereForToken": "Klicka här för att hämta din token" + "clickHereForToken": "Klicka här för att hämta din token", + "selectAllLibraries": "Välj alla bibliotek", + "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" + }, + "validation": { + "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Sökväg", "size": "Storlek", - "updatedAt": "Försvann" + "updatedAt": "Försvann", + "libraryName": "Bibliotek" }, "actions": { "remove": "Radera", @@ -261,6 +271,58 @@ "removed": "Saknade fil(er) borttagna" }, "empty": "Inga saknade filer" + }, + "library": { + "name": "Bibliotek |||| Bibliotek", + "fields": { + "name": "Namn", + "path": "Sökväg", + "remotePath": "Ta bort sökväg", + "lastScanAt": "Senaste scan", + "songCount": "Låtar", + "albumCount": "Album", + "artistCount": "Artister", + "totalSongs": "Låtar", + "totalAlbums": "Album", + "totalArtists": "Artister", + "totalFolders": "Mappar", + "totalFiles": "Filer", + "totalMissingFiles": "Saknade filer", + "totalSize": "Sammanlagd storlek", + "totalDuration": "Längd", + "defaultNewUsers": "Standard för nya användare", + "createdAt": "Skapad", + "updatedAt": "Uppdaterad" + }, + "sections": { + "basic": "Grundinformation", + "statistics": "Statistik" + }, + "actions": { + "scan": "Scanna bibliotek", + "manageUsers": "Hantera användaråtkomst", + "viewDetails": "Se detaljer" + }, + "notifications": { + "created": "Biblioteket har skapats", + "updated": "Biblioteket har uppdaterats", + "deleted": "Biblioteket har raderats", + "scanStarted": "Biblioteksscan startad", + "scanCompleted": "Biblioteksscan avslutad" + }, + "validation": { + "nameRequired": "Biblioteksnamn krävs", + "pathRequired": "Bibliotekssökväg krävs", + "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", + "pathNotFound": "Bibliotekssökväg hittades inte", + "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", + "pathInvalid": "Ogiltig bibliotekssökväg" + }, + "messages": { + "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", + "scanInProgress": "Scanning pågår...", + "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" + } } }, "ra": { @@ -473,7 +535,13 @@ "albumList": "Album", "about": "Om", "playlists": "Spellistor", - "sharedPlaylists": "Delade spellistor" + "sharedPlaylists": "Delade spellistor", + "librarySelector": { + "allLibraries": "Alla bibliotek (%{count})", + "multipleLibraries": "%{selected} av %{total} bibliotek", + "selectLibraries": "Valda bibliotek", + "none": "Inga" + } }, "player": { "playListsText": "Spela kön", diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index d412189ff..7c1a82c08 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -35,7 +35,8 @@ "rawTags": "Ham etiketler", "bitDepth": "Bit derinliği", "sampleRate": "Örnekleme Oranı", - "missing": "Eksik" + "missing": "Eksik", + "libraryName": "Kütüphane" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -76,7 +77,8 @@ "media": "Medya", "mood": "Mod", "date": "Kayıt Tarihi", - "missing": "Eksik" + "missing": "Eksik", + "libraryName": "Kütüphane" }, "actions": { "playAll": "Oynat", @@ -147,10 +149,12 @@ "currentPassword": "Mevcut Şifre", "newPassword": "Yeni Şifre", "token": "Token", - "lastAccessAt": "Son Erişim Tarihi" + "lastAccessAt": "Son Erişim Tarihi", + "libraries": "Kütüphaneler" }, "helperTexts": { - "name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir" + "name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir", + "libraries": "Bu kullanıcı için belirli kütüphaneleri seçin veya varsayılan kütüphaneleri kullanmak için boş bırakın" }, "notifications": { "created": "Kullanıcı oluşturuldu", @@ -159,7 +163,12 @@ }, "message": { "listenBrainzToken": "ListenBrainz kullanıcı Token'ınızı girin.", - "clickHereForToken": "Token almak için buraya tıklayın" + "clickHereForToken": "Token almak için buraya tıklayın", + "selectAllLibraries": "Tüm kütüphaneleri seç", + "adminAutoLibraries": "Yönetici yetkili kullanıcılar tüm kütüphanelere otomatik olarak erişebilir" + }, + "validation": { + "librariesRequired": "Yönetici olmayan kullanıcılar için en az bir kütüphane seçilmelidir" } }, "player": { @@ -251,7 +260,8 @@ "fields": { "path": "Yol", "size": "Boyut", - "updatedAt": "Kaybolma" + "updatedAt": "Kaybolma", + "libraryName": "Kütüphane" }, "actions": { "remove": "Kaldır", @@ -261,6 +271,58 @@ "removed": "Eksik dosya(lar) kaldırıldı" }, "empty": "Eksik Dosya Yok" + }, + "library": { + "name": "Kütüphane |||| Kütüphaneler", + "fields": { + "name": "İsim", + "path": "Yol", + "remotePath": "Uzak Yol", + "lastScanAt": "Son Tarama", + "songCount": "Şarkılar", + "albumCount": "Albümler", + "artistCount": "Sanatçılar", + "totalSongs": "Şarkılar", + "totalAlbums": "Albümler", + "totalArtists": "Sanatçılar", + "totalFolders": "Klasörler", + "totalFiles": "Dosyalar", + "totalMissingFiles": "Eksik Dosyalar", + "totalSize": "Toplam Boyut", + "totalDuration": "Süre", + "defaultNewUsers": "Yeni Kullanıcılar için Varsayılan", + "createdAt": "Oluşturuldu", + "updatedAt": "Güncellendi" + }, + "sections": { + "basic": "Temel Bilgiler", + "statistics": "İstatistikler" + }, + "actions": { + "scan": "Kütüphaneyi Tara", + "manageUsers": "Kullanıcı Erişimini Yönet", + "viewDetails": "Ayrıntıları Görüntüle" + }, + "notifications": { + "created": "Kütüphane başarıyla oluşturuldu", + "updated": "Kütüphane başarıyla güncellendi", + "deleted": "Kütüphane başarıyla silindi", + "scanStarted": "Kütüphane taraması başladı", + "scanCompleted": "Kütüphane taraması tamamlandı" + }, + "validation": { + "nameRequired": "Kütüphane adı gereklidir", + "pathRequired": "Kütüphane yolu gereklidir", + "pathNotDirectory": "Kütüphane yolu bir dizin olmalıdır", + "pathNotFound": "Kütüphane yolu bulunamadı", + "pathNotAccessible": "Kütüphane yoluna erişim sağlanamıyor", + "pathInvalid": "Geçersiz kütüphane yolu" + }, + "messages": { + "deleteConfirm": "Bu kütüphaneyi silmek istediğinizden emin misiniz? Bu işlem, ilgili tüm verileri ve kullanıcı erişimini kaldıracaktır.", + "scanInProgress": "Tarama devam ediyor...", + "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı" + } } }, "ra": { @@ -473,7 +535,13 @@ "albumList": "Albümler", "about": "Hakkında", "playlists": "Çalma Listeleri", - "sharedPlaylists": "Paylaşılan Çalma Listeleri" + "sharedPlaylists": "Paylaşılan Çalma Listeleri", + "librarySelector": { + "allLibraries": "Tüm Kitaplıklar (%{count})", + "multipleLibraries": "%{total} kütüphaneden %{selected} tanesi seçildi", + "selectLibraries": "Seçili Kütüphaneler", + "none": "Hiçbiri" + } }, "player": { "playListsText": "Oynatma Sırası", diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index a8be902c9..c500a7457 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -35,7 +35,8 @@ "rawTags": "Вихідні теги", "bitDepth": "Глибина розрядності", "sampleRate": "Частота дискретизації", - "missing": "Поле відсутнє" + "missing": "Поле відсутнє", + "libraryName": "Бібліотека" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -44,7 +45,8 @@ "shuffleAll": "Перемішати", "download": "Завантажити", "playNext": "Наступна", - "info": "Отримати інформацію" + "info": "Отримати інформацію", + "showInPlaylist": "Показати у плейлісті" } }, "album": { @@ -75,7 +77,8 @@ "media": "Медіа", "mood": "Настрій", "date": "Дата запису", - "missing": "Поле відсутнє" + "missing": "Поле відсутнє", + "libraryName": "Бібліотека" }, "actions": { "playAll": "Прослухати", @@ -123,7 +126,13 @@ "mixer": "Звукоінженер |||| Звукоінженери", "remixer": "Реміксер |||| Реміксери", "djmixer": "DJ-звукоінженер |||| DJ-звукоінженери", - "performer": "Виконавець |||| Виконавці" + "performer": "Виконавець |||| Виконавці", + "maincredit": "Виконавець альбому або Виконавець |||| Виконавці альбому або Виконавці" + }, + "actions": { + "shuffle": "Перетасовка", + "radio": "Радіо", + "topSongs": "ТОП-треки" } }, "user": { @@ -140,10 +149,12 @@ "currentPassword": "Поточний пароль", "newPassword": "Новий пароль", "token": "Токен", - "lastAccessAt": "Останній доступ" + "lastAccessAt": "Останній доступ", + "libraries": "Бібліотеки" }, "helperTexts": { - "name": "Змінене ім'я буде відображатися при наступній авторизації" + "name": "Змінене ім'я буде відображатися при наступній авторизації", + "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням" }, "notifications": { "created": "Користувача створено", @@ -152,7 +163,12 @@ }, "message": { "listenBrainzToken": "Введіть свій токен користувача ListenBrainz.", - "clickHereForToken": "Натисніть тут для отримання токену" + "clickHereForToken": "Натисніть тут для отримання токену", + "selectAllLibraries": "Вибрати всі бібліотеки", + "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек" + }, + "validation": { + "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека" } }, "player": { @@ -197,11 +213,16 @@ "export": "Експортувати", "makePublic": "Зробити публічним", "makePrivate": "Зробити приватним", - "saveQueue": "Зберегти чергу до плейлиста" + "saveQueue": "Зберегти чергу до плейлиста", + "searchOrCreate": "Знайти плейлист або введіть текст, щоб створити новий...", + "pressEnterToCreate": "Натисніть Enter щоб створити новий плейлист", + "removeFromSelection": "Вилучити з вибору" }, "message": { "duplicate_song": "Додати повторювані пісні", - "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?" + "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?", + "noPlaylistsFound": "Не знайдено плейлистів", + "noPlaylists": "Немає доступних плейлистів" } }, "radio": { @@ -239,7 +260,8 @@ "fields": { "path": "Шлях файлу", "size": "Розмір", - "updatedAt": "Зник" + "updatedAt": "Зник", + "libraryName": "Бібліотека" }, "actions": { "remove": "Видалити", @@ -249,6 +271,58 @@ "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": { @@ -430,7 +504,9 @@ "remove_missing_title": "Видалити зниклі файли", "remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.", "remove_all_missing_title": "Видалити всі відсутні файли", - "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами." + "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.", + "noSimilarSongsFound": "Не знайдено схожих треків", + "noTopSongsFound": "Не знайдено ТОП-треків" }, "menu": { "library": "Бібліотека", @@ -459,7 +535,13 @@ "albumList": "Альбом", "about": "Довідка", "playlists": "Списки відтворення", - "sharedPlaylists": "Загальнодоступний список відтворення" + "sharedPlaylists": "Загальнодоступний список відтворення", + "librarySelector": { + "allLibraries": "Усі бібліотеки (%{count})", + "multipleLibraries": "%{selected} з %{total} Бібліотеки", + "selectLibraries": "Вибір бібліотек", + "none": "Відсутня" + } }, "player": { "playListsText": "Грати по черзі", @@ -496,6 +578,21 @@ "disabled": "Вимкнено", "waiting": "Очікування" } + }, + "tabs": { + "about": "Про", + "config": "Конфігурація" + }, + "config": { + "configName": "Назва конфігурації", + "environmentVariable": "Змінна середовища", + "currentValue": "Поточне значення", + "configurationFile": "Файл конфігурації", + "exportToml": "Експортувати Конфігурацію (у форматі TOML)", + "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML", + "exportFailed": "Не вдалося скопіювати конфігурацію", + "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)", + "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях." } }, "activity": { @@ -522,5 +619,10 @@ "toggle_love": "Відмітити поточні пісні", "current_song": "Перейти до поточної пісні" } + }, + "nowPlaying": { + "title": "Зараз грає", + "empty": "Нічого не грає", + "minutesAgo": "%{smart_count} хвилин тому |||| %{smart_count} хвилин тому" } } \ No newline at end of file From d28a282de4c2d4c10cca23d13f216c59dcbb9e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 26 Jul 2025 11:27:35 -0400 Subject: [PATCH 29/64] fix(scanner): Apple Music playlists import for songs with accented characters (#4385) * fix: resolve playlist import issues with Unicode character paths Fixes #3332 where songs with accented characters failed to import from Apple Music M3U playlists. The issue occurred because Apple Music exports use NFC Unicode normalization while macOS filesystem stores paths in NFD normalization. Added normalizePathForComparison() function that normalizes both filesystem and M3U playlist paths to NFC form before comparison. This ensures consistent path matching regardless of the Unicode normalization form used. Changes include comprehensive test coverage for Unicode normalization scenarios with both NFC and NFD character representations. * address comments Signed-off-by: Deluan <deluan@navidrome.org> * fix(tests): add check for unequal original Unicode paths in playlist normalization tests Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/playlists.go | 12 +++++++++-- core/playlists_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/core/playlists.go b/core/playlists.go index 1d998f1e3..2eebc94e7 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/text/unicode/norm" ) type Playlists interface { @@ -203,10 +204,10 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m } existing := make(map[string]int, len(found)) for idx := range found { - existing[strings.ToLower(found[idx].Path)] = idx + existing[normalizePathForComparison(found[idx].Path)] = idx } for _, path := range paths { - idx, ok := existing[strings.ToLower(path)] + idx, ok := existing[normalizePathForComparison(path)] if ok { mfs = append(mfs, found[idx]) } else { @@ -223,6 +224,13 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m return nil } +// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase +// for consistent comparison. This fixes Unicode normalization issues on macOS where +// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD. +func normalizePathForComparison(path string) string { + return strings.ToLower(norm.NFC.String(path)) +} + // TODO This won't work for multiple libraries func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) { libRegex, err := s.compileLibraryPaths(ctx) diff --git a/core/playlists_test.go b/core/playlists_test.go index 3a3c9aafc..399210ac8 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "golang.org/x/text/unicode/norm" ) var _ = Describe("Playlists", func() { @@ -186,6 +187,54 @@ var _ = Describe("Playlists", func() { Expect(pls.Tracks).To(HaveLen(1)) Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) }) + + It("handles Unicode normalization when comparing paths", func() { + // Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD + // The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent) + + const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a" + + // Simulate a database entry with NFD encoding (as stored by macOS filesystem) + nfdPath := norm.NFD.String(pathWithAccents) + repo.data = []string{nfdPath} + + // Simulate an Apple Music M3U playlist entry with NFC encoding + nfcPath := norm.NFC.String("/music/" + pathWithAccents) + m3u := strings.Join([]string{ + nfcPath, + }, "\n") + f := strings.NewReader(m3u) + + pls, err := ps.ImportM3U(ctx, f) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences") + Expect(pls.Tracks[0].Path).To(Equal(nfdPath)) + }) + }) + + Describe("normalizePathForComparison", func() { + It("normalizes Unicode characters to NFC form and converts to lowercase", func() { + // Test with NFD (decomposed) input - as would come from macOS filesystem + nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form + normalized := normalizePathForComparison(nfdPath) + Expect(normalized).To(Equal("michèle")) + + // Test with NFC (composed) input - as would come from Apple Music M3U + nfcPath := "Michèle" // This might be in NFC form + normalizedNfc := normalizePathForComparison(nfcPath) + + // Ensure the two paths are not equal in their original forms + Expect(nfdPath).ToNot(Equal(nfcPath)) + + // Both should normalize to the same result + Expect(normalized).To(Equal(normalizedNfc)) + }) + + It("handles paths with mixed case and Unicode characters", func() { + path := "Artist/Noël Coward/Album/Song.mp3" + normalized := normalizePathForComparison(path) + Expect(normalized).To(Equal("artist/noël coward/album/song.mp3")) + }) }) Describe("InPlaylistsPath", func() { From 3e61b0426b0cf4428dbe948938c199165679c6d4 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 26 Jul 2025 21:40:41 -0400 Subject: [PATCH 30/64] fix(scanner): custom tags working again Signed-off-by: Deluan <deluan@navidrome.org> --- model/tag_mappings.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/model/tag_mappings.go b/model/tag_mappings.go index 0365ed565..bfe098f77 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -138,8 +138,10 @@ func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp { escaped = append(escaped, regexp.QuoteMeta(s)) } // If no valid separators remain, return the original value. - if len(split) > 0 && len(escaped) == 0 { - log.Warn("No valid separators found in split list", "split", split, "tag", tagName) + if len(escaped) == 0 { + if len(split) > 0 { + log.Warn("No valid separators found in split list", "split", split, "tag", tagName) + } return nil } From 5ea14ba520269db99d96213446fa9456b4dc9cf2 Mon Sep 17 00:00:00 2001 From: Cristiandis <112336919+Cristiandis@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:04:33 +0200 Subject: [PATCH 31/64] docs(plugins): fix README.md for Discord Rich Presence (#4387) --- plugins/examples/discord-rich-presence/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md index 8cb97224a..80b12166f 100644 --- a/plugins/examples/discord-rich-presence/README.md +++ b/plugins/examples/discord-rich-presence/README.md @@ -59,7 +59,7 @@ clientID, users, err := d.getConfig(ctx) Add the following to `navidrome.toml` and adjust for your tokens: ```toml -[PluginSettings.discord-rich-presence] +[PluginConfig.discord-rich-presence] ClientID = "123456789012345678" Users = "alice:token123,bob:token456" ``` From d75ebc5efd3e4c2a661fabe073e4aa960e9b9052 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:18:49 +0000 Subject: [PATCH 32/64] fix(plugins): don't log "no proxy IP found" when using Subsonic API in plugins with reverse proxy auth (#4388) * fix(auth): Do not try reverse proxy auth if internal auth succeeds * cmp.Or will still require function results to be evaluated... * move to a function --- server/subsonic/middlewares.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index b8f01c83e..af1ba448f 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -47,11 +47,23 @@ func postFormToQueryParams(next http.Handler) http.Handler { }) } +func fromInternalOrProxyAuth(r *http.Request) (string, bool) { + username := server.InternalAuth(r) + + // If the username comes from internal auth, do not also do reverse proxy auth, as + // the request will have no reverse proxy IP + if username != "" { + return username, true + } + + return server.UsernameFromReverseProxyHeader(r), false +} + func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string - username := cmp.Or(server.InternalAuth(r), server.UsernameFromReverseProxyHeader(r)) + username, _ := fromInternalOrProxyAuth(r) if username != "" { requiredParameters = []string{"v", "c"} } else { @@ -91,10 +103,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { var usr *model.User var err error - internalAuth := server.InternalAuth(r) - proxyAuth := server.UsernameFromReverseProxyHeader(r) - if username := cmp.Or(internalAuth, proxyAuth); username != "" { - authType := If(internalAuth != "", "internal", "reverse-proxy") + username, isInternalAuth := fromInternalOrProxyAuth(r) + if username != "" { + authType := If(isInternalAuth, "internal", "reverse-proxy") usr, err = ds.User(ctx).FindByUsername(username) if errors.Is(err, context.Canceled) { log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) From 77e47f1ea21e3c3d27a47b489be2d5fbe2d77f4d Mon Sep 17 00:00:00 2001 From: Akshat Mehta <2023pietcaakshat004@poornima.org> Date: Mon, 28 Jul 2025 20:51:27 +0530 Subject: [PATCH 33/64] feat(ui): add Hindi language translation (#4390) * Hindi Language Support for "Navidrome" Added Hindi Language Support * Little changes for this Language and more well structured --- resources/i18n/hi.json | 630 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 resources/i18n/hi.json diff --git a/resources/i18n/hi.json b/resources/i18n/hi.json new file mode 100644 index 000000000..5b9ece530 --- /dev/null +++ b/resources/i18n/hi.json @@ -0,0 +1,630 @@ +{ + "languageName": "हिंदी", + "resources": { + "song": { + "name": "गाना |||| गाने", + "fields": { + "albumArtist": "एल्बम कलाकार", + "duration": "समय", + "trackNumber": "#", + "playCount": "प्ले संख्या", + "title": "शीर्षक", + "artist": "कलाकार", + "album": "एल्बम", + "path": "फ़ाइल पथ", + "libraryName": "लाइब्रेरी", + "genre": "शैली", + "compilation": "संकलन", + "year": "वर्ष", + "size": "फ़ाइल का आकार", + "updatedAt": "अपडेट किया गया", + "bitRate": "बिट रेट", + "bitDepth": "बिट गहराई", + "sampleRate": "सैंपल रेट", + "channels": "चैनल", + "discSubtitle": "डिस्क उपशीर्षक", + "starred": "पसंदीदा", + "comment": "टिप्पणी", + "rating": "रेटिंग", + "quality": "गुणवत्ता", + "bpm": "BPM", + "playDate": "अंतिम बार चलाया गया", + "createdAt": "जोड़ने की तारीख", + "grouping": "समूहीकरण", + "mood": "मूड", + "participants": "अतिरिक्त प्रतिभागी", + "tags": "अतिरिक्त टैग", + "mappedTags": "मैप किए गए टैग", + "rawTags": "रॉ टैग", + "missing": "गुम" + }, + "actions": { + "addToQueue": "बाद में चलाएं", + "playNow": "अभी चलाएं", + "addToPlaylist": "प्लेलिस्ट में जोड़ें", + "showInPlaylist": "प्लेलिस्ट में दिखाएं", + "shuffleAll": "सभी को शफल करें", + "download": "डाउनलोड", + "playNext": "अगला चलाएं", + "info": "जानकारी प्राप्त करें" + } + }, + "album": { + "name": "एल्बम |||| एल्बम", + "fields": { + "albumArtist": "एल्बम कलाकार", + "artist": "कलाकार", + "duration": "समय", + "songCount": "गाने", + "playCount": "प्ले संख्या", + "size": "आकार", + "name": "नाम", + "libraryName": "लाइब्रेरी", + "genre": "शैली", + "compilation": "संकलन", + "year": "वर्ष", + "date": "रिकॉर्डिंग की तारीख", + "originalDate": "मूल", + "releaseDate": "रिलीज़", + "releases": "रिलीज़ |||| रिलीज़", + "released": "रिलीज़ किया गया", + "updatedAt": "अपडेट किया गया", + "comment": "टिप्पणी", + "rating": "रेटिंग", + "createdAt": "जोड़ने की तारीख", + "recordLabel": "लेबल", + "catalogNum": "कैटलॉग नंबर", + "releaseType": "प्रकार", + "grouping": "समूहीकरण", + "media": "मीडिया", + "mood": "मूड", + "missing": "गुम" + }, + "actions": { + "playAll": "चलाएं", + "playNext": "अगला चलाएं", + "addToQueue": "बाद में चलाएं", + "share": "साझा करें", + "shuffle": "शफल", + "addToPlaylist": "प्लेलिस्ट में जोड़ें", + "download": "डाउनलोड", + "info": "जानकारी प्राप्त करें" + }, + "lists": { + "all": "सभी", + "random": "रैंडम", + "recentlyAdded": "हाल ही में जोड़े गए", + "recentlyPlayed": "हाल ही में चलाए गए", + "mostPlayed": "सबसे ज्यादा चलाए गए", + "starred": "पसंदीदा", + "topRated": "टॉप रेटेड" + } + }, + "artist": { + "name": "कलाकार |||| कलाकार", + "fields": { + "name": "नाम", + "albumCount": "एल्बम की संख्या", + "songCount": "गानों की संख्या", + "size": "आकार", + "playCount": "प्ले संख्या", + "rating": "रेटिंग", + "genre": "शैली", + "role": "भूमिका", + "missing": "गुम" + }, + "roles": { + "albumartist": "एल्बम कलाकार |||| एल्बम कलाकार", + "artist": "कलाकार |||| कलाकार", + "composer": "संगीतकार |||| संगीतकार", + "conductor": "संचालक |||| संचालक", + "lyricist": "गीतकार |||| गीतकार", + "arranger": "संयोजक |||| संयोजक", + "producer": "निर्माता |||| निर्माता", + "director": "निदेशक |||| निदेशक", + "engineer": "इंजीनियर |||| इंजीनियर", + "mixer": "मिक्सर |||| मिक्सर", + "remixer": "रीमिक्सर |||| रीमिक्सर", + "djmixer": "डीजे मिक्सर |||| डीजे मिक्सर", + "performer": "कलाकार |||| कलाकार", + "maincredit": "एल्बम कलाकार या कलाकार |||| एल्बम कलाकार या कलाकार" + }, + "actions": { + "topSongs": "टॉप गाने", + "shuffle": "शफल", + "radio": "रेडियो" + } + }, + "user": { + "name": "उपयोगकर्ता |||| उपयोगकर्ता", + "fields": { + "userName": "उपयोगकर्ता नाम", + "isAdmin": "एडमिन है", + "lastLoginAt": "अंतिम लॉगिन", + "lastAccessAt": "अंतिम पहुंच", + "updatedAt": "अपडेट किया गया", + "name": "नाम", + "password": "पासवर्ड", + "createdAt": "बनाया गया", + "changePassword": "पासवर्ड बदलें?", + "currentPassword": "वर्तमान पासवर्ड", + "newPassword": "नया पासवर्ड", + "token": "टोकन", + "libraries": "लाइब्रेरी" + }, + "helperTexts": { + "name": "आपके नाम में परिवर्तन केवल अगली लॉगिन पर प्रभावी होगा", + "libraries": "इस उपयोगकर्ता के लिए विशिष्ट लाइब्रेरी चुनें, या डिफ़ॉल्ट लाइब्रेरी का उपयोग करने के लिए खाली छोड़ें" + }, + "notifications": { + "created": "उपयोगकर्ता बनाया गया", + "updated": "उपयोगकर्ता अपडेट किया गया", + "deleted": "उपयोगकर्ता हटाया गया" + }, + "validation": { + "librariesRequired": "गैर-एडमिन उपयोगकर्ताओं के लिए कम से कम एक लाइब्रेरी चुननी होगी" + }, + "message": { + "listenBrainzToken": "अपना ListenBrainz उपयोगकर्ता टोकन दर्ज करें।", + "clickHereForToken": "अपना टोकन प्राप्त करने के लिए यहां क्लिक करें", + "selectAllLibraries": "सभी लाइब्रेरी चुनें", + "adminAutoLibraries": "एडमिन उपयोगकर्ताओं की सभी लाइब्रेरी तक स्वचालित पहुंच है" + } + }, + "player": { + "name": "प्लेयर |||| प्लेयर", + "fields": { + "name": "नाम", + "transcodingId": "ट्रांसकोडिंग", + "maxBitRate": "अधिकतम बिट रेट", + "client": "क्लाइंट", + "userName": "उपयोगकर्ता नाम", + "lastSeen": "अंतिम बार देखा गया", + "reportRealPath": "वास्तविक पथ रिपोर्ट करें", + "scrobbleEnabled": "बाहरी सेवाओं को स्क्रॉबल भेजें" + } + }, + "transcoding": { + "name": "ट्रांसकोडिंग |||| ट्रांसकोडिंग", + "fields": { + "name": "नाम", + "targetFormat": "लक्ष्य प्रारूप", + "defaultBitRate": "डिफ़ॉल्ट बिट रेट", + "command": "कमांड" + } + }, + "playlist": { + "name": "प्लेलिस्ट |||| प्लेलिस्ट", + "fields": { + "name": "नाम", + "duration": "अवधि", + "ownerName": "मालिक", + "public": "सार्वजनिक", + "updatedAt": "अपडेट किया गया", + "createdAt": "बनाया गया", + "songCount": "गाने", + "comment": "टिप्पणी", + "sync": "ऑटो-इंपोर्ट", + "path": "से इंपोर्ट करें" + }, + "actions": { + "selectPlaylist": "एक प्लेलिस्ट चुनें:", + "addNewPlaylist": "\"%{name}\" बनाएं", + "export": "निर्यात", + "saveQueue": "क्यू को प्लेलिस्ट में सेव करें", + "makePublic": "सार्वजनिक बनाएं", + "makePrivate": "निजी बनाएं", + "searchOrCreate": "प्लेलिस्ट खोजें या नई बनाने के लिए टाइप करें...", + "pressEnterToCreate": "नई प्लेलिस्ट बनाने के लिए Enter दबाएं", + "removeFromSelection": "चयन से हटाएं" + }, + "message": { + "duplicate_song": "डुप्लिकेट गाने जोड़ें", + "song_exist": "प्लेलिस्ट में डुप्लिकेट जोड़े जा रहे हैं। क्या आप डुप्लिकेट जोड़ना चाहते हैं या उन्हें छोड़ना चाहते हैं?", + "noPlaylistsFound": "कोई प्लेलिस्ट नहीं मिली", + "noPlaylists": "कोई प्लेलिस्ट उपलब्ध नहीं" + } + }, + "radio": { + "name": "रेडियो |||| रेडियो", + "fields": { + "name": "नाम", + "streamUrl": "स्ट्रीम URL", + "homePageUrl": "होम पेज URL", + "updatedAt": "अपडेट किया गया", + "createdAt": "बनाया गया" + }, + "actions": { + "playNow": "अभी चलाएं" + } + }, + "share": { + "name": "साझा |||| साझा", + "fields": { + "username": "द्वारा साझा किया गया", + "url": "URL", + "description": "विवरण", + "downloadable": "डाउनलोड की अनुमति दें?", + "contents": "सामग्री", + "expiresAt": "समाप्त होता है", + "lastVisitedAt": "अंतिम बार देखा गया", + "visitCount": "विज़िट", + "format": "प्रारूप", + "maxBitRate": "अधिकतम बिट रेट", + "updatedAt": "अपडेट किया गया", + "createdAt": "बनाया गया" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "गुम फ़ाइल |||| गुम फ़ाइलें", + "empty": "कोई गुम फ़ाइल नहीं", + "fields": { + "path": "पथ", + "size": "आकार", + "libraryName": "लाइब्रेरी", + "updatedAt": "गायब हुई" + }, + "actions": { + "remove": "हटाएं", + "remove_all": "सभी हटाएं" + }, + "notifications": { + "removed": "गुम फ़ाइल(एं) हटा दी गईं" + } + }, + "library": { + "name": "लाइब्रेरी |||| लाइब्रेरी", + "fields": { + "name": "नाम", + "path": "पथ", + "remotePath": "रिमोट पथ", + "lastScanAt": "अंतिम स्कैन", + "songCount": "गाने", + "albumCount": "एल्बम", + "artistCount": "कलाकार", + "totalSongs": "गाने", + "totalAlbums": "एल्बम", + "totalArtists": "कलाकार", + "totalFolders": "फ़ोल्डर", + "totalFiles": "फ़ाइलें", + "totalMissingFiles": "गुम फ़ाइलें", + "totalSize": "कुल आकार", + "totalDuration": "अवधि", + "defaultNewUsers": "नए उपयोगकर्ताओं के लिए डिफ़ॉल्ट", + "createdAt": "बनाया गया", + "updatedAt": "अपडेट किया गया" + }, + "sections": { + "basic": "बुनियादी जानकारी", + "statistics": "आंकड़े" + }, + "actions": { + "scan": "लाइब्रेरी स्कैन करें", + "manageUsers": "उपयोगकर्ता पहुंच प्रबंधित करें", + "viewDetails": "विवरण देखें" + }, + "notifications": { + "created": "लाइब्रेरी सफलतापूर्वक बनाई गई", + "updated": "लाइब्रेरी सफलतापूर्वक अपडेट की गई", + "deleted": "लाइब्रेरी सफलतापूर्वक हटाई गई", + "scanStarted": "लाइब्रेरी स्कैन शुरू किया गया", + "scanCompleted": "लाइब्रेरी स्कैन पूरा हुआ" + }, + "validation": { + "nameRequired": "लाइब्रेरी का नाम आवश्यक है", + "pathRequired": "लाइब्रेरी पथ आवश्यक है", + "pathNotDirectory": "लाइब्रेरी पथ एक डायरेक्टरी होना चाहिए", + "pathNotFound": "लाइब्रेरी पथ नहीं मिला", + "pathNotAccessible": "लाइब्रेरी पथ पहुंच योग्य नहीं है", + "pathInvalid": "अमान्य लाइब्रेरी पथ" + }, + "messages": { + "deleteConfirm": "क्या आप वाकई इस लाइब्रेरी को हटाना चाहते हैं? इससे सभी संबंधित डेटा और उपयोगकर्ता पहुंच हट जाएगी।", + "scanInProgress": "स्कैन चल रहा है...", + "noLibrariesAssigned": "इस उपयोगकर्ता को कोई लाइब्रेरी असाइन नहीं की गई" + } + } + }, + "ra": { + "auth": { + "welcome1": "Navidrome इंस्टॉल करने के लिए धन्यवाद!", + "welcome2": "शुरू करने के लिए, एक एडमिन उपयोगकर्ता बनाएं", + "confirmPassword": "पासवर्ड की पुष्टि करें", + "buttonCreateAdmin": "एडमिन बनाएं", + "auth_check_error": "जारी रखने के लिए कृपया लॉगिन करें", + "user_menu": "प्रोफ़ाइल", + "username": "उपयोगकर्ता नाम", + "password": "पासवर्ड", + "sign_in": "साइन इन", + "sign_in_error": "प्रमाणीकरण विफल, कृपया पुनः प्रयास करें", + "logout": "लॉगआउट", + "insightsCollectionNote": "Navidrome परियोजना को बेहतर बनाने में मदद के लिए\nअज्ञात उपयोग डेटा एकत्र करता है। अधिक जानने\nऔर चाहें तो ऑप्ट-आउट करने के लिए [यहां] क्लिक करें" + }, + "validation": { + "invalidChars": "कृपया केवल अक्षर और संख्याओं का उपयोग करें।", + "passwordDoesNotMatch": "पासवर्ड मेल नहीं खाता।", + "required": "आवश्यक", + "minLength": "कम से कम %{min} अक्षर होने चाहिए", + "maxLength": "%{max} अक्षर या उससे कम होने चाहिए", + "minValue": "कम से कम %{min} होना चाहिए", + "maxValue": "%{max} या उससे कम होना चाहिए", + "number": "एक संख्या होनी चाहिए", + "email": "एक वैध ईमेल होना चाहिए", + "oneOf": "इनमें से एक होना चाहिए: %{options}", + "regex": "एक विशिष्ट प्रारूप से मेल खाना चाहिए (regexp): %{pattern}", + "unique": "अद्वितीय होना चाहिए", + "url": "एक वैध URL होना चाहिए" + }, + "action": { + "add_filter": "फ़िल्टर जोड़ें", + "add": "जोड़ें", + "back": "वापस जाएं", + "bulk_actions": "1 आइटम चुना गया |||| %{smart_count} आइटम चुने गए", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "रद्द करें", + "clear_input_value": "मान साफ़ करें", + "clone": "क्लोन", + "confirm": "पुष्टि करें", + "create": "बनाएं", + "delete": "हटाएं", + "edit": "संपादित करें", + "export": "निर्यात", + "list": "सूची", + "refresh": "रीफ्रेश", + "remove_filter": "इस फ़िल्टर को हटाएं", + "remove": "हटाएं", + "save": "सेव करें", + "search": "खोजें", + "show": "दिखाएं", + "sort": "क्रमबद्ध करें", + "undo": "पूर्ववत करें", + "expand": "विस्तार करें", + "close": "बंद करें", + "open_menu": "मेनू खोलें", + "close_menu": "मेनू बंद करें", + "unselect": "चयन हटाएं", + "skip": "छोड़ें", + "share": "साझा करें", + "download": "डाउनलोड" + }, + "boolean": { + "true": "हां", + "false": "नहीं" + }, + "page": { + "create": "%{name} बनाएं", + "dashboard": "डैशबोर्ड", + "edit": "%{name} #%{id}", + "error": "कुछ गलत हुआ", + "list": "%{name}", + "loading": "लोड हो रहा है", + "not_found": "नहीं मिला", + "show": "%{name} #%{id}", + "empty": "अभी तक कोई %{name} नहीं।", + "invite": "क्या आप एक जोड़ना चाहते हैं?" + }, + "input": { + "file": { + "upload_several": "अपलोड करने के लिए कुछ फ़ाइलें छोड़ें, या चुनने के लिए क्लिक करें।", + "upload_single": "अपलोड करने के लिए एक फ़ाइल छोड़ें, या इसे चुनने के लिए क्लिक करें।" + }, + "image": { + "upload_several": "अपलोड करने के लिए कुछ तस्वीरें छोड़ें, या चुनने के लिए क्लिक करें।", + "upload_single": "अपलोड करने के लिए एक तस्वीर छोड़ें, या इसे चुनने के लिए क्लिक करें।" + }, + "references": { + "all_missing": "संदर्भ डेटा खोजने में असमर्थ।", + "many_missing": "संबंधित संदर्भों में से कम से कम एक अब उपलब्ध नहीं लगता।", + "single_missing": "संबंधित संदर्भ अब उपलब्ध नहीं लगता।" + }, + "password": { + "toggle_visible": "पासवर्ड छुपाएं", + "toggle_hidden": "पासवर्ड दिखाएं" + } + }, + "message": { + "about": "के बारे में", + "are_you_sure": "क्या आप सुनिश्चित हैं?", + "bulk_delete_content": "क्या आप वाकई इस %{name} को हटाना चाहते हैं? |||| क्या आप वाकई इन %{smart_count} आइटमों को हटाना चाहते हैं?", + "bulk_delete_title": "%{name} हटाएं |||| %{smart_count} %{name} हटाएं", + "delete_content": "क्या आप वाकई इस आइटम को हटाना चाहते हैं?", + "delete_title": "%{name} #%{id} हटाएं", + "details": "विवरण", + "error": "एक क्लाइंट त्रुटि हुई और आपका अनुरोध पूरा नहीं हो सका।", + "invalid_form": "फॉर्म मान्य नहीं है। कृपया त्रुटियों की जांच करें।", + "loading": "पेज लोड हो रहा है, कृपया एक क्षण प्रतीक्षा करें", + "no": "नहीं", + "not_found": "या तो आपने गलत URL टाइप किया है, या आपने गलत लिंक फॉलो किया है।", + "yes": "हां", + "unsaved_changes": "आपके कुछ बदलाव सेव नहीं हुए। क्या आप वाकई उन्हें नज़रअंदाज़ करना चाहते हैं?" + }, + "navigation": { + "no_results": "कोई परिणाम नहीं मिला", + "no_more_results": "पेज नंबर %{page} सीमा से बाहर है। पिछले पेज को आज़माएं।", + "page_out_of_boundaries": "पेज नंबर %{page} सीमा से बाहर", + "page_out_from_end": "अंतिम पेज के बाद नहीं जा सकते", + "page_out_from_begin": "पेज 1 से पहले नहीं जा सकते", + "page_range_info": "%{total} में से %{offsetBegin}-%{offsetEnd}", + "page_rows_per_page": "प्रति पेज आइटम:", + "next": "अगला", + "prev": "पिछला", + "skip_nav": "सामग्री पर जाएं" + }, + "notification": { + "updated": "एलिमेंट अपडेट किया गया |||| %{smart_count} एलिमेंट अपडेट किए गए", + "created": "एलिमेंट बनाया गया", + "deleted": "एलिमेंट हटाया गया |||| %{smart_count} एलिमेंट हटाए गए", + "bad_item": "गलत एलिमेंट", + "item_doesnt_exist": "एलिमेंट मौजूद नहीं है", + "http_error": "सर्वर संचार त्रुटि", + "data_provider_error": "dataProvider त्रुटि। विवरण के लिए कंसोल जांचें।", + "i18n_error": "निर्दिष्ट भाषा के लिए अनुवाद लोड नहीं हो सकते", + "canceled": "कार्रवाई रद्द की गई", + "logged_out": "आपका सत्र समाप्त हो गया है, कृपया फिर से कनेक्ट करें।", + "new_version": "नया संस्करण उपलब्ध! कृपया इस विंडो को रीफ्रेश करें।" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "प्रदर्शित करने वाले कॉलम", + "layout": "लेआउट", + "grid": "ग्रिड", + "table": "टेबल" + } + }, + "message": { + "note": "नोट", + "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 सफलतापूर्वक लिंक किया गया और स्क्रॉबलिंग सक्षम की गई", + "lastfmLinkFailure": "Last.fm लिंक नहीं हो सका", + "lastfmUnlinkSuccess": "Last.fm अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई", + "lastfmUnlinkFailure": "Last.fm अनलिंक नहीं हो सका", + "listenBrainzLinkSuccess": "ListenBrainz सफलतापूर्वक लिंक किया गया और उपयोगकर्ता के रूप में स्क्रॉबलिंग सक्षम की गई: %{user}", + "listenBrainzLinkFailure": "ListenBrainz लिंक नहीं हो सका: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई", + "listenBrainzUnlinkFailure": "ListenBrainz अनलिंक नहीं हो सका", + "openIn": { + "lastfm": "Last.fm में खोलें", + "musicbrainz": "MusicBrainz में खोलें" + }, + "lastfmLink": "और पढ़ें...", + "shareOriginalFormat": "मूल प्रारूप में साझा करें", + "shareDialogTitle": "%{resource} '%{name}' साझा करें", + "shareBatchDialogTitle": "1 %{resource} साझा करें |||| %{smart_count} %{resource} साझा करें", + "shareCopyToClipboard": "क्लिपबोर्ड में कॉपी करें: Ctrl+C, Enter", + "shareSuccess": "URL क्लिपबोर्ड में कॉपी किया गया: %{url}", + "shareFailure": "URL %{url} को क्लिपबोर्ड में कॉपी करने में त्रुटि", + "downloadDialogTitle": "%{resource} '%{name}' (%{size}) डाउनलोड करें", + "downloadOriginalFormat": "मूल प्रारूप में डाउनलोड करें" + }, + "menu": { + "library": "लाइब्रेरी", + "librarySelector": { + "allLibraries": "सभी लाइब्रेरी (%{count})", + "multipleLibraries": "%{total} में से %{selected} लाइब्रेरी", + "selectLibraries": "लाइब्रेरी चुनें", + "none": "कोई नहीं" + }, + "settings": "सेटिंग्स", + "version": "संस्करण", + "theme": "थीम", + "personal": { + "name": "व्यक्तिगत", + "options": { + "theme": "थीम", + "language": "भाषा", + "defaultView": "डिफ़ॉल्ट दृश्य", + "desktop_notifications": "डेस्कटॉप सूचनाएं", + "lastfmNotConfigured": "Last.fm API-Key कॉन्फ़िगर नहीं है", + "lastfmScrobbling": "Last.fm में स्क्रॉबल करें", + "listenBrainzScrobbling": "ListenBrainz में स्क्रॉबल करें", + "replaygain": "ReplayGain मोड", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "अक्षम", + "album": "एल्बम गेन का उपयोग करें", + "track": "ट्रैक गेन का उपयोग करें" + } + } + }, + "albumList": "एल्बम", + "playlists": "प्लेलिस्ट", + "sharedPlaylists": "साझा की गई प्लेलिस्ट", + "about": "के बारे में" + }, + "player": { + "playListsText": "प्ले क्यू", + "openText": "खोलें", + "closeText": "बंद करें", + "notContentText": "कोई संगीत नहीं", + "clickToPlayText": "चलाने के लिए क्लिक करें", + "clickToPauseText": "रोकने के लिए क्लिक करें", + "nextTrackText": "अगला ट्रैक", + "previousTrackText": "पिछला ट्रैक", + "reloadText": "रीलोड", + "volumeText": "वॉल्यूम", + "toggleLyricText": "गीत टॉगल करें", + "toggleMiniModeText": "मिनिमाइज़ करें", + "destroyText": "नष्ट करें", + "downloadText": "डाउनलोड", + "removeAudioListsText": "ऑडियो सूची हटाएं", + "clickToDeleteText": "%{name} को हटाने के लिए क्लिक करें", + "emptyLyricText": "कोई गीत नहीं", + "playModeText": { + "order": "क्रम में", + "orderLoop": "दोहराएं", + "singleLoop": "एक दोहराएं", + "shufflePlay": "शफल" + } + }, + "about": { + "links": { + "homepage": "होम पेज", + "source": "सोर्स कोड", + "featureRequests": "फीचर अनुरोध", + "lastInsightsCollection": "अंतिम अंतर्दृष्टि संग्रह", + "insights": { + "disabled": "अक्षम", + "waiting": "प्रतीक्षा में" + } + }, + "tabs": { + "about": "के बारे में", + "config": "कॉन्फ़िगरेशन" + }, + "config": { + "configName": "कॉन्फ़िग नाम", + "environmentVariable": "पर्यावरण चर", + "currentValue": "वर्तमान मान", + "configurationFile": "कॉन्फ़िगरेशन फ़ाइल", + "exportToml": "कॉन्फ़िगरेशन निर्यात करें (TOML)", + "exportSuccess": "कॉन्फ़िगरेशन TOML प्रारूप में क्लिपबोर्ड में निर्यात किया गया", + "exportFailed": "कॉन्फ़िगरेशन कॉपी करने में विफल", + "devFlagsHeader": "विकास फ्लैग (परिवर्तन/हटाने के अधीन)", + "devFlagsComment": "ये प्रयोगात्मक सेटिंग्स हैं और भविष्य के संस्करणों में हटाई जा सकती हैं" + } + }, + "activity": { + "title": "गतिविधि", + "totalScanned": "कुल स्कैन किए गए फ़ोल्डर", + "quickScan": "त्वरित स्कैन", + "fullScan": "पूर्ण स्कैन", + "serverUptime": "सर्वर अपटाइम", + "serverDown": "ऑफलाइन", + "scanType": "प्रकार", + "status": "स्कैन त्रुटि", + "elapsedTime": "बीता समय" + }, + "nowPlaying": { + "title": "अभी चल रहा है", + "empty": "कुछ नहीं चल रहा", + "minutesAgo": "%{smart_count} मिनट पहले |||| %{smart_count} मिनट पहले" + }, + "help": { + "title": "Navidrome हॉटकीज़", + "hotkeys": { + "show_help": "यह सहायता दिखाएं", + "toggle_menu": "मेनू साइड बार टॉगल करें", + "toggle_play": "चलाएं / रोकें", + "prev_song": "पिछला गाना", + "next_song": "अगला गाना", + "current_song": "वर्तमान गाने पर जाएं", + "vol_up": "वॉल्यूम बढ़ाएं", + "vol_down": "वॉल्यूम कम करें", + "toggle_love": "इस ट्रैक को पसंदीदा में जोड़ें" + } + } +} From d9aa3529d7de32655235627a7152e672e521df02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 28 Jul 2025 11:23:50 -0400 Subject: [PATCH 34/64] fix(ui): update Polish translations from POEditor (#4384) Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org> --- resources/i18n/pl.json | 145 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 16 deletions(-) diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index e75a9f404..4d78c7599 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -33,7 +33,10 @@ "tags": "Dodatkowe Tagi", "mappedTags": "Zmapowane tagi", "rawTags": "Surowe tagi", - "bitDepth": "Głębokość próbkowania" + "bitDepth": "Głębokość próbkowania", + "sampleRate": "Częstotliwość próbkowania", + "missing": "Brak", + "libraryName": "Biblioteka" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -42,7 +45,8 @@ "shuffleAll": "Losuj Wszystkie", "download": "Pobierz", "playNext": "Odtwarzaj Następny", - "info": "Zdobądź Informacje" + "info": "Zdobądź Informacje", + "showInPlaylist": "Pokaż w Liście Odtwarzania" } }, "album": { @@ -72,7 +76,9 @@ "grouping": "Grupowanie", "media": "Media", "mood": "Nastrój", - "date": "" + "date": "Data Nagrania", + "missing": "Brak", + "libraryName": "Biblioteka" }, "actions": { "playAll": "Odtwarzaj", @@ -104,7 +110,8 @@ "rating": "Ocena", "genre": "Gatunek", "size": "Rozmiar", - "role": "Rola" + "role": "Rola", + "missing": "Brak" }, "roles": { "albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu", @@ -119,7 +126,13 @@ "mixer": "Mikser |||| Mikserzy", "remixer": "Remixer |||| Remixerzy", "djmixer": "Didżej |||| Didżerzy", - "performer": "Wykonawca |||| Wykonawcy" + "performer": "Wykonawca |||| Wykonawcy", + "maincredit": "Artysta albumu lub Artysta |||| Artyści albumu lub Artyści" + }, + "actions": { + "shuffle": "Losuj", + "radio": "Radio", + "topSongs": "Najlepsze Utwory" } }, "user": { @@ -136,10 +149,12 @@ "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "token": "Token", - "lastAccessAt": "Ostatnia Aktywność" + "lastAccessAt": "Ostatnia Aktywność", + "libraries": "Biblioteki" }, "helperTexts": { - "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu" + "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu", + "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki" }, "notifications": { "created": "Dodano użytkownika", @@ -148,7 +163,12 @@ }, "message": { "listenBrainzToken": "Wprowadź swój token ListenBrainz.", - "clickHereForToken": "Kliknij tutaj, aby uzyskać token" + "clickHereForToken": "Kliknij tutaj, aby uzyskać token", + "selectAllLibraries": "Wybierz wszystkie biblioteki", + "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek" + }, + "validation": { + "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika" } }, "player": { @@ -192,11 +212,17 @@ "addNewPlaylist": "Stwórz \"%{name}\"", "export": "Wyeksportuj", "makePublic": "Zmień na Publiczną", - "makePrivate": "Zmień na Prywatną" + "makePrivate": "Zmień na Prywatną", + "saveQueue": "Zapisz Kolejkę do Playlisty", + "searchOrCreate": "Szukaj list odtwarzania lub zacznij pisać, aby stworzyć nową...", + "pressEnterToCreate": "Wciśnij Enter, aby stworzyć nową listę odtwarzania", + "removeFromSelection": "Usuń z zaznaczenia" }, "message": { "duplicate_song": "Dodaj zduplikowane utwory", - "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?" + "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?", + "noPlaylistsFound": "Brak list odtwarzania", + "noPlaylists": "Brak dostępnych list odtwarzania" } }, "radio": { @@ -234,15 +260,69 @@ "fields": { "path": "Ścieżka", "size": "Rozmiar", - "updatedAt": "Zniknął na" + "updatedAt": "Zniknął na", + "libraryName": "Biblioteka" }, "actions": { - "remove": "Usuń" + "remove": "Usuń", + "remove_all": "Usuń Wszystko" }, "notifications": { "removed": "Usunięto brakujące pliki" }, - "empty": "Bez Brakujących Plików" + "empty": "Brak Brakujących Plików" + }, + "library": { + "name": "Biblioteka |||| Biblioteki", + "fields": { + "name": "Nazwa", + "path": "Ścieżka", + "remotePath": "Zdalna Ścieżka", + "lastScanAt": "Ostatni Skan", + "songCount": "Utwory", + "albumCount": "Albumy", + "artistCount": "Artyści", + "totalSongs": "Utwory", + "totalAlbums": "Albumy", + "totalArtists": "Artyści", + "totalFolders": "Foldery", + "totalFiles": "Pliki", + "totalMissingFiles": "Brakujące Pliki", + "totalSize": "Całkowity Rozmiar", + "totalDuration": "Czas Trwania", + "defaultNewUsers": "Domyślne dla Nowych Użytkowników", + "createdAt": "Stworzona", + "updatedAt": "Zaktualizowana" + }, + "sections": { + "basic": "Podstawowe Informacje", + "statistics": "Statystyki" + }, + "actions": { + "scan": "Skanuj Bibliotekę", + "manageUsers": "Zarządzaj Dostępami Użytkownika", + "viewDetails": "Zobacz Szczegóły" + }, + "notifications": { + "created": "Biblioteka utworzona prawidłowo", + "updated": "Biblioteka zaktualizowana prawidłowo", + "deleted": "Biblioteka usunięta prawidłowo", + "scanStarted": "Rozpoczęto skan biblioteki", + "scanCompleted": "Zakończono skan biblioteki" + }, + "validation": { + "nameRequired": "Nazwa biblioteki jest wymagana", + "pathRequired": "Ścieżka biblioteki jest wymagana", + "pathNotDirectory": "Ścieżka biblioteki musi być katalogiem", + "pathNotFound": "Brak ścieżki biblioteki", + "pathNotAccessible": "Ścieżka biblioteki niedostępna", + "pathInvalid": "Niepoprawna ścieżka biblioteki" + }, + "messages": { + "deleteConfirm": "Czy chcesz usunąć tę bibliotekę? Spowoduje to usunięcie wszystkich powiązanych danych i dostępów użytkowników.", + "scanInProgress": "Skanowanie w trakcie...", + "noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika" + } } }, "ra": { @@ -422,7 +502,11 @@ "downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter", "remove_missing_title": "Usuń brakujące dane", - "remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny." + "remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny.", + "remove_all_missing_title": "Usuń wszystkie brakujące pliki", + "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.", + "noSimilarSongsFound": "Brak podobnych utworów", + "noTopSongsFound": "Brak najlepszych utworów" }, "menu": { "library": "Biblioteka", @@ -451,7 +535,13 @@ "albumList": "Albumy", "about": "O aplikacji", "playlists": "Playlisty", - "sharedPlaylists": "Udostępnione Playlisty" + "sharedPlaylists": "Udostępnione Playlisty", + "librarySelector": { + "allLibraries": "Wszystkie Biblioteki (%{count})", + "multipleLibraries": "%{selected} z %{total} Bibliotek", + "selectLibraries": "Wybierz Biblioteki", + "none": "Żadna" + } }, "player": { "playListsText": "Kolejka Odtwarzania", @@ -488,6 +578,21 @@ "disabled": "Wyłączone", "waiting": "Oczekujące" } + }, + "tabs": { + "about": "O", + "config": "Konfiguracja" + }, + "config": { + "configName": "Nazwa Konfiguracji", + "environmentVariable": "Zmienna Środowiskowa", + "currentValue": "Obecna Wartość", + "configurationFile": "Plik Konfiguracyjny", + "exportToml": "Eksportuj Konfigurację (TOML)", + "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML", + "exportFailed": "Błąd kopiowania konfiguracji", + "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)", + "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach" } }, "activity": { @@ -496,7 +601,10 @@ "quickScan": "Szybkie Skanowanie", "fullScan": "Pełne Skanowanie", "serverUptime": "Czas Działania Serwera", - "serverDown": "NIEDOSTĘPNY" + "serverDown": "NIEDOSTĘPNY", + "scanType": "Typ", + "status": "Błąd Skanowania", + "elapsedTime": "Upłynięty Czas" }, "help": { "title": "Skróty Klawiszowe Navidrome", @@ -511,5 +619,10 @@ "toggle_love": "Dodaj ten utwór do ulubionych", "current_song": "Przejdź do Bieżącego Utworu" } + }, + "nowPlaying": { + "title": "Teraz Odtwarzane", + "empty": "Nic nie jest odtwarzane", + "minutesAgo": "%{smart_count} minutę temu |||| %{smart_count} minut temu" } } \ No newline at end of file From 9dbe0c183efce2eb040bade7c02a1bb7117972e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 28 Jul 2025 13:21:10 -0400 Subject: [PATCH 35/64] feat(insights): add plugin and multi-library information (#4391) * feat(plugins): add PluginList method Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance insights collection with plugin awareness and expanded metrics Enhanced the insights collection system to provide more comprehensive telemetry data about Navidrome installations. This update adds plugin awareness through dependency injection integration, expands configuration detection capabilities, and includes additional library metrics. Key improvements include: - Added PluginLoader interface integration to collect plugin information when enabled - Enhanced configuration detection with proper credential validation for LastFM, Spotify, and Deezer - Added new library metrics including Libraries count and smart playlist detection - Expanded configuration insights with reverse proxy, custom PID, and custom tags detection - Updated Wire dependency injection to support the new plugin loader requirement - Added corresponding data structures for plugin information collection This enhancement provides valuable insights into feature usage patterns and plugin adoption while maintaining privacy and following existing telemetry practices. * fix: correct type assertion in plugin manager test Fixed type mismatch in test where PluginManifestCapabilitiesElem was being compared with string literal. The test now properly casts the string to the correct enum type for comparison. * refactor: move static config checks to staticData function Moved HasCustomTags, ReverseProxyConfigured, and HasCustomPID configuration checks from the dynamic collect() function to the static staticData() function where they belong. This eliminates redundant computation on every insights collection cycle and implements the actual logic for HasCustomTags instead of the hardcoded false value. The HasCustomTags field now properly detects if custom tags are configured by checking the length of conf.Server.Tags. This change improves performance by computing static configuration values only once rather than on every insights collection. * feat: add granular control for insights collection Added DevEnablePluginsInsights configuration option to allow fine-grained control over whether plugin information is collected as part of the insights data. This change enhances privacy controls by allowing users to opt-out of plugin reporting while still participating in general insights collection. The implementation includes: - New configuration option DevEnablePluginsInsights with default value true - Gated plugin collection in insights.go based on both plugin enablement and permission flag - Enhanced plugin information to include version data alongside name - Improved code organization with clearer conditional logic for data collection * refactor: rename PluginNames parameter from serviceName to capability Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- cmd/wire_gen.go | 16 +++++---- cmd/wire_injectors.go | 1 + conf/configuration.go | 2 ++ core/agents/agents.go | 2 +- core/metrics/insights.go | 62 ++++++++++++++++++++++++++++++---- core/metrics/insights/data.go | 12 +++++++ core/scrobbler/play_tracker.go | 2 +- plugins/manager.go | 47 ++++++++++++++++++-------- plugins/manager_test.go | 15 +++++--- 9 files changed, 125 insertions(+), 34 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index ee5fd025e..187ab488d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -47,7 +47,9 @@ func CreateServer() *server.Server { sqlDB := db.Db() dataStore := persistence.New(sqlDB) broker := events.GetBroker() - insights := metrics.GetInstance(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) serverServer := server.New(dataStore, broker, insights) return serverServer } @@ -57,11 +59,11 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) playlists := core.NewPlaylists(dataStore) - insights := metrics.GetInstance(dataStore) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) @@ -134,7 +136,9 @@ func CreateListenBrainzRouter() *listenbrainz.Router { func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - insights := metrics.GetInstance(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) return insights } @@ -197,7 +201,7 @@ func getPluginManager() plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ec469b8be..e8759ac53 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -44,6 +44,7 @@ var allProviders = wire.NewSet( db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), + wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), ) diff --git a/conf/configuration.go b/conf/configuration.go index bb1ae120b..7292c7dfe 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -127,6 +127,7 @@ type configOptions struct { DevScannerThreads uint DevInsightsInitialDelay time.Duration DevEnablePlayerInsights bool + DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 } @@ -601,6 +602,7 @@ func setViperDefaults() { viper.SetDefault("devscannerthreads", 5) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devenableplayerinsights", true) + viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) } diff --git a/core/agents/agents.go b/core/agents/agents.go index 225411ecd..4ec324b71 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -17,7 +17,7 @@ import ( // PluginLoader defines an interface for loading plugins type PluginLoader interface { // PluginNames returns the names of all plugins that implement a particular service - PluginNames(serviceName string) []string + PluginNames(capability string) []string // LoadMediaAgent loads and returns a media agent plugin LoadMediaAgent(name string) (Interface, bool) } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 29284a908..f4f8738e7 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics/insights" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/schema" "github.com/navidrome/navidrome/utils/singleton" ) @@ -34,12 +35,18 @@ var ( ) type insightsCollector struct { - ds model.DataStore - lastRun atomic.Int64 - lastStatus atomic.Bool + ds model.DataStore + pluginLoader PluginLoader + lastRun atomic.Int64 + lastStatus atomic.Bool } -func GetInstance(ds model.DataStore) Insights { +// PluginLoader defines an interface for loading plugins +type PluginLoader interface { + PluginList() map[string]schema.PluginManifest +} + +func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights { return singleton.GetInstance(func() *insightsCollector { id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey) if err != nil { @@ -51,7 +58,7 @@ func GetInstance(ds model.DataStore) Insights { } } insightsID = id - return &insightsCollector{ds: ds} + return &insightsCollector{ds: ds, pluginLoader: pluginLoader} }) } @@ -180,10 +187,11 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.EnableDownloads = conf.Server.EnableDownloads data.Config.EnableSharing = conf.Server.EnableSharing data.Config.EnableStarRating = conf.Server.EnableStarRating - data.Config.EnableLastFM = conf.Server.LastFM.Enabled + data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" + data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled + data.Config.EnableDeezer = conf.Server.Deezer.Enabled data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt - data.Config.EnableSpotify = conf.Server.Spotify.ID != "" data.Config.EnableJukebox = conf.Server.Jukebox.Enabled data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize @@ -199,6 +207,9 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != "" + data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" + data.Config.HasCustomTags = len(conf.Server.Tags) > 0 return data }) @@ -233,12 +244,29 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { if err != nil { log.Trace(ctx, "Error reading radios count", err) } + data.Library.Libraries, err = c.ds.Library(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading libraries count", err) + } data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { log.Trace(ctx, "Error reading active users count", err) } + + // Check for smart playlists + data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx) + if err != nil { + log.Trace(ctx, "Error checking for smart playlists", err) + } + + // Collect plugins if permitted and enabled + if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled { + data.Plugins = c.collectPlugins(ctx) + } + + // Collect active players if permitted if conf.Server.DevEnablePlayerInsights { data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{ Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)}, @@ -264,3 +292,23 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { } return resp } + +// hasSmartPlaylists checks if there are any smart playlists (playlists with rules) +func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) { + count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}}, + }) + return count > 0, err +} + +// collectPlugins collects information about installed plugins +func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo { + plugins := make(map[string]insights.PluginInfo) + for id, manifest := range c.pluginLoader.PluginList() { + plugins[id] = insights.PluginInfo{ + Name: manifest.Name, + Version: manifest.Version, + } + } + return plugins +} diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 85c1ad18b..105a6218e 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -36,6 +36,7 @@ type Data struct { Playlists int64 `json:"playlists"` Shares int64 `json:"shares"` Radios int64 `json:"radios"` + Libraries int64 `json:"libraries"` ActiveUsers int64 `json:"activeUsers"` ActivePlayers map[string]int64 `json:"activePlayers,omitempty"` } `json:"library"` @@ -55,6 +56,7 @@ type Data struct { EnableStarRating bool `json:"enableStarRating,omitempty"` EnableLastFM bool `json:"enableLastFM,omitempty"` EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` EnableSpotify bool `json:"enableSpotify,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` @@ -69,7 +71,17 @@ type Data struct { BackupCount int `json:"backupCount,omitempty"` DevActivityPanel bool `json:"devActivityPanel,omitempty"` DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` + Plugins map[string]PluginInfo `json:"plugins,omitempty"` +} + +type PluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` } type FSInfo struct { diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 6c017c0bc..3b71a2100 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -40,7 +40,7 @@ type PlayTracker interface { // PluginLoader is a minimal interface for plugin manager usage in PlayTracker // (avoids import cycles) type PluginLoader interface { - PluginNames(service string) []string + PluginNames(capability string) []string LoadScrobbler(name string) (Scrobbler, bool) } diff --git a/plugins/manager.go b/plugins/manager.go index 7c735c740..35a1130fd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -87,7 +87,8 @@ type SubsonicRouter http.Handler type Manager interface { SetSubsonicRouter(router SubsonicRouter) EnsureCompiled(name string) error - PluginNames(serviceName string) []string + PluginList() map[string]schema.PluginManifest + PluginNames(capability string) []string LoadPlugin(name string, capability string) WasmPlugin LoadMediaAgent(name string) (agents.Interface, bool) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) @@ -97,7 +98,7 @@ type Manager interface { // managerImpl is a singleton that manages plugins type managerImpl struct { plugins map[string]*plugin // Map of plugin folder name to plugin info - mu sync.RWMutex // Protects plugins map + pluginsMu sync.RWMutex // Protects plugins map subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router schedulerService *schedulerService // Service for handling scheduled tasks websocketService *websocketService // Service for handling WebSocket connections @@ -166,7 +167,7 @@ func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manif } // Register the plugin first - m.mu.Lock() + m.pluginsMu.Lock() m.plugins[pluginID] = p // Register one plugin adapter for each capability @@ -187,7 +188,7 @@ func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manif } m.adapters[pluginID+"_"+capabilityStr] = adapter } - m.mu.Unlock() + m.pluginsMu.Unlock() log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink) return m.plugins[pluginID] @@ -210,8 +211,8 @@ func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) { // unregisterPlugin removes a plugin from the manager func (m *managerImpl) unregisterPlugin(pluginID string) { - m.mu.Lock() - defer m.mu.Unlock() + m.pluginsMu.Lock() + defer m.pluginsMu.Unlock() plugin, ok := m.plugins[pluginID] if !ok { @@ -234,10 +235,10 @@ func (m *managerImpl) unregisterPlugin(pluginID string) { // ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use. func (m *managerImpl) ScanPlugins() { // Clear existing plugins - m.mu.Lock() + m.pluginsMu.Lock() m.plugins = make(map[string]*plugin) m.adapters = make(map[string]WasmPlugin) - m.mu.Unlock() + m.pluginsMu.Unlock() // Get plugins directory from config root := conf.Server.Plugins.Folder @@ -297,10 +298,24 @@ func (m *managerImpl) ScanPlugins() { log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames) } +// PluginList returns a map of all registered plugins with their manifests +func (m *managerImpl) PluginList() map[string]schema.PluginManifest { + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + + // Create a map to hold the plugin manifests + pluginList := make(map[string]schema.PluginManifest, len(m.plugins)) + for name, plugin := range m.plugins { + // Use the plugin ID as the key and the manifest as the value + pluginList[name] = *plugin.Manifest + } + return pluginList +} + // PluginNames returns the folder names of all plugins that implement the specified capability func (m *managerImpl) PluginNames(capability string) []string { - m.mu.RLock() - defer m.mu.RUnlock() + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() var names []string for name, plugin := range m.plugins { @@ -315,8 +330,8 @@ func (m *managerImpl) PluginNames(capability string) []string { } func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) { - m.mu.RLock() - defer m.mu.RUnlock() + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() info, infoOk := m.plugins[name] adapter, adapterOk := m.adapters[name+"_"+capability] @@ -356,9 +371,9 @@ func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin { // This is useful when you need to wait for compilation without loading a specific capability, // such as during plugin refresh operations or health checks. func (m *managerImpl) EnsureCompiled(name string) error { - m.mu.RLock() + m.pluginsMu.RLock() plugin, ok := m.plugins[name] - m.mu.RUnlock() + m.pluginsMu.RUnlock() if !ok { return fmt.Errorf("plugin not found: %s", name) @@ -393,7 +408,9 @@ func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {} func (n noopManager) EnsureCompiled(name string) error { return nil } -func (n noopManager) PluginNames(serviceName string) []string { return nil } +func (n noopManager) PluginList() map[string]schema.PluginManifest { return nil } + +func (n noopManager) PluginNames(capability string) []string { return nil } func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil } diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 2a6ad575f..207908ebc 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -65,6 +65,13 @@ var _ = Describe("Plugin Manager", func() { Expect(schedulerCallbackNames).To(ContainElement("multi_plugin")) }) + It("should load all plugins from folder", func() { + all := mgr.PluginList() + Expect(all).To(HaveLen(6)) + Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent")) + Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent"))) + }) + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) Expect(plugin).NotTo(BeNil()) @@ -332,9 +339,9 @@ var _ = Describe("Plugin Manager", func() { } // Register the plugin in the manager - mgr.mu.Lock() + mgr.pluginsMu.Lock() mgr.plugins[plugin.ID] = plugin - mgr.mu.Unlock() + mgr.pluginsMu.Unlock() // Mark the plugin as initialized in the lifecycle manager mgr.lifecycle.markInitialized(plugin) @@ -344,9 +351,9 @@ var _ = Describe("Plugin Manager", func() { mgr.unregisterPlugin(plugin.ID) // Verify that the plugin is no longer in the manager - mgr.mu.RLock() + mgr.pluginsMu.RLock() _, exists := mgr.plugins[plugin.ID] - mgr.mu.RUnlock() + mgr.pluginsMu.RUnlock() Expect(exists).To(BeFalse()) // Verify that the lifecycle state has been cleared From b2ee5b51566169b3d24219dbfa825f3ab6d3e40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20=C5=A0ehi=C4=87?= <73797105+MuxBH28@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:06:09 +0200 Subject: [PATCH 36/64] feat(ui): add new Bosnian translation (#4399) Update translations for Bosnian language --- resources/i18n/bs.json | 628 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 628 insertions(+) create mode 100644 resources/i18n/bs.json diff --git a/resources/i18n/bs.json b/resources/i18n/bs.json new file mode 100644 index 000000000..9d5c552e7 --- /dev/null +++ b/resources/i18n/bs.json @@ -0,0 +1,628 @@ +{ + "languageName": "Bosanski", + "resources": { + "song": { + "name": "Pjesma |||| Pjesme", + "fields": { + "albumArtist": "Izvođač albuma", + "duration": "Trajanje", + "trackNumber": "Pjesma #", + "playCount": "Reprodukcija", + "title": "Naslov", + "artist": "Izvođač", + "album": "Album", + "path": "Putanja datoteke", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Godina", + "size": "Veličina datoteke", + "updatedAt": "Dodano", + "bitRate": "Brzina prijenosa", + "discSubtitle": "Podnaslov CD-a", + "starred": "Favorit", + "comment": "Komentar", + "rating": "Ocjena", + "quality": "Kvaliteta", + "bpm": "BPM", + "playDate": "Posljednja reprodukcija", + "channels": "Kanali", + "createdAt": "Dodano", + "grouping": "Grupisanje", + "mood": "Raspoloženje", + "participants": "Dodatni učesnici", + "tags": "Dodatne oznake", + "mappedTags": "Mapirane oznake", + "rawTags": "Sirovi podaci oznaka", + "bitDepth": "Dubina bita", + "sampleRate": "Uzorkovanje", + "missing": "Nedostaje", + "libraryName": "Biblioteka" + }, + "actions": { + "addToQueue": "Reprodukcija kasnije", + "playNow": "Reprodukcija sada", + "addToPlaylist": "Dodaj u playlistu", + "shuffleAll": "Nasumična reprodukcija", + "download": "Preuzmi", + "playNext": "Reprodukcija sljedeće", + "info": "Više informacija", + "showInPlaylist": "Prikaži u playlisti" + } + }, + "album": { + "name": "Album |||| Albumi", + "fields": { + "albumArtist": "Izvođač albuma", + "artist": "Izvođač", + "duration": "Trajanje", + "songCount": "Broj pjesama", + "playCount": "Reprodukcija", + "name": "Naziv", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Godina", + "updatedAt": "Ažurirano", + "comment": "Komentar", + "rating": "Ocjena", + "createdAt": "Dodano", + "size": "Veličina", + "originalDate": "Originalni datum", + "releaseDate": "Datum izdanja", + "releases": "Izdanje |||| Izdanja", + "released": "Objavljeno", + "recordLabel": "Izdavač", + "catalogNum": "Kataloški broj", + "releaseType": "Tip", + "grouping": "Grupisanje", + "media": "Medij", + "mood": "Raspoloženje", + "date": "Datum snimanja", + "missing": "Nedostaje", + "libraryName": "Biblioteka" + }, + "actions": { + "playAll": "Reprodukcija", + "playNext": "Reprodukcija sljedeće", + "addToQueue": "Dodaj u red", + "shuffle": "Nasumična reprodukcija", + "addToPlaylist": "Dodaj u playlistu", + "download": "Preuzmi", + "info": "Više informacija", + "share": "Podijeli" + }, + "lists": { + "all": "Sve", + "random": "Nasumično", + "recentlyAdded": "Nedavno dodano", + "recentlyPlayed": "Nedavno reproducirano", + "mostPlayed": "Najviše reproducirano", + "starred": "Favoriti", + "topRated": "Najbolje ocijenjeno" + } + }, + "artist": { + "name": "Izvođač |||| Izvođači", + "fields": { + "name": "Naziv", + "albumCount": "Broj albuma", + "songCount": "Broj pjesama", + "playCount": "Reprodukcija", + "rating": "Ocjena", + "genre": "Žanr", + "size": "Veličina", + "role": "Uloga", + "missing": "Nedostaje" + }, + "roles": { + "albumartist": "Izvođač albuma |||| Izvođači albuma", + "artist": "Izvođač |||| Izvođači", + "composer": "Kompozitor |||| Kompozitori", + "conductor": "Dirigent |||| Dirigenti", + "lyricist": "Tekstopisac |||| Tekstopisci", + "arranger": "Aranžer |||| Aranžeri", + "producer": "Producent |||| Producenti", + "director": "Direktor |||| Direktori", + "engineer": "Inženjer |||| Inženjeri", + "mixer": "Mikser |||| Mikseri", + "remixer": "Remikser |||| Remikseri", + "djmixer": "DJ Mikser |||| DJ Mikseri", + "performer": "Izvođač |||| Izvođači", + "maincredit": "Izvođač albuma ili izvođač |||| Izvođači albuma ili izvođači" + }, + "actions": { + "shuffle": "Nasumična reprodukcija", + "radio": "Radio", + "topSongs": "Najpopularnije pjesme" + } + }, + "user": { + "name": "Korisnik |||| Korisnici", + "fields": { + "userName": "Korisničko ime", + "isAdmin": "Je admin", + "lastLoginAt": "Posljednja prijava", + "updatedAt": "Ažurirano", + "name": "Ime", + "password": "Lozinka", + "createdAt": "Kreirano", + "changePassword": "Promijeni lozinku?", + "currentPassword": "Trenutna lozinka", + "newPassword": "Nova lozinka", + "token": "Token", + "lastAccessAt": "Posljednji pristup", + "libraries": "Biblioteke" + }, + "helperTexts": { + "name": "Promjena će biti aktivna nakon sljedeće prijave", + "libraries": "Odaberi specifične biblioteke za ovog korisnika ili ostavi prazno za standardne biblioteke" + }, + "notifications": { + "created": "Korisnik kreiran", + "updated": "Korisnik ažuriran", + "deleted": "Korisnik obrisan" + }, + "message": { + "listenBrainzToken": "Unesite svoj ListenBrainz korisnički token", + "clickHereForToken": "Kliknite ovdje za dobijanje tokena", + "selectAllLibraries": "Odaberi sve biblioteke", + "adminAutoLibraries": "Administratori automatski imaju pristup svim bibliotekama" + }, + "validation": { + "librariesRequired": "Ne-administratori moraju imati barem jednu odabranu biblioteku" + } + }, + "player": { + "name": "Player |||| Playeri", + "fields": { + "name": "Naziv", + "transcodingId": "ID transkodiranja", + "maxBitRate": "Maks. brzina prijenosa", + "client": "Klijent", + "userName": "Korisničko ime", + "lastSeen": "Posljednji put viđen", + "reportRealPath": "Prikaži stvarnu putanju", + "scrobbleEnabled": "Slanje podataka o reprodukciji (scrobbling)" + } + }, + "transcoding": { + "name": "Transkodiranje |||| Transkodiranja", + "fields": { + "name": "Naziv", + "targetFormat": "Ciljani format", + "defaultBitRate": "Zadana brzina prijenosa", + "command": "Komanda" + } + }, + "playlist": { + "name": "Playlista |||| Playliste", + "fields": { + "name": "Naziv", + "duration": "Trajanje", + "ownerName": "Vlasnik", + "public": "Javna", + "updatedAt": "Ažurirano", + "createdAt": "Kreirano", + "songCount": "Broj pjesama", + "comment": "Komentar", + "sync": "Auto-uvoz", + "path": "Uvezi iz" + }, + "actions": { + "selectPlaylist": "Odaberi playlistu:", + "addNewPlaylist": "Kreiraj \"%{name}\"", + "export": "Izvezi", + "makePublic": "Učini javnom", + "makePrivate": "Učini privatnom", + "saveQueue": "Sačuvaj red čekanja u playlistu", + "searchOrCreate": "Pretraži playlistu ili kreiraj novu...", + "pressEnterToCreate": "Pritisni Enter za kreiranje nove playliste", + "removeFromSelection": "Ukloni iz odabira" + }, + "message": { + "duplicate_song": "Dodaj duplikate", + "song_exist": "Neke pjesme su već u playlisti. Želiš li ih ipak dodati ili preskočiti?", + "noPlaylistsFound": "Nije pronađena nijedna playlista", + "noPlaylists": "Nema playlisti" + } + }, + "radio": { + "name": "Radio |||| Radiji", + "fields": { + "name": "Naziv", + "streamUrl": "Stream URL", + "homePageUrl": "URL početne stranice", + "updatedAt": "Ažurirano", + "createdAt": "Dodano" + }, + "actions": { + "playNow": "Reprodukcija sada" + } + }, + "share": { + "name": "Dijeljenje |||| Dijeljenja", + "fields": { + "username": "Podijeljeno od strane", + "url": "URL", + "description": "Opis", + "contents": "Sadržaj", + "expiresAt": "Vrijedi do", + "lastVisitedAt": "Posljednja posjeta", + "visitCount": "Posjete", + "format": "Format", + "maxBitRate": "Maks. brzina prijenosa", + "updatedAt": "Ažurirano", + "createdAt": "Kreirano", + "downloadable": "Dozvoli preuzimanje?" + } + }, + "missing": { + "name": "Nedostajuća datoteka |||| Nedostajuće datoteke", + "fields": { + "path": "Putanja", + "size": "Veličina", + "updatedAt": "Nedostaje od", + "libraryName": "Biblioteka" + }, + "actions": { + "remove": "Ukloni", + "remove_all": "ukloni sve" + }, + "notifications": { + "removed": "Nedostajuća(e) datoteka(e) uklonjena(e)" + }, + "empty": "nema nedostajućih datoteka" + }, + "library": { + "name": "Biblioteka |||| Biblioteke", + "fields": { + "name": "Naziv", + "path": "Putanja", + "remotePath": "Udaljena putanja", + "lastScanAt": "Posljednje skeniranje", + "songCount": "Pjesme", + "albumCount": "Albumi", + "artistCount": "Izvođači", + "totalSongs": "Pjesme", + "totalAlbums": "Albumi", + "totalArtists": "Izvođači", + "totalFolders": "Folderi", + "totalFiles": "Datoteke", + "totalMissingFiles": "Nedostajuće datoteke", + "totalSize": "Veličina", + "totalDuration": "Trajanje", + "defaultNewUsers": "Standardno za nove korisnike", + "createdAt": "Kreirano", + "updatedAt": "Ažurirano" + }, + "sections": { + "basic": "Osnovne informacije", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skeniraj biblioteku", + "manageUsers": "Upravljaj pristupima", + "viewDetails": "Pogledaj detalje" + }, + "notifications": { + "created": "Biblioteka uspješno kreirana", + "updated": "Biblioteka uspješno ažurirana", + "deleted": "Biblioteka uspješno obrisana", + "scanStarted": "Skeniranje biblioteke započeto", + "scanCompleted": "Skeniranje biblioteke završeno" + }, + "validation": { + "nameRequired": "Naziv biblioteke je obavezan", + "pathRequired": "Putanja biblioteke je obavezna", + "pathNotDirectory": "Putanja biblioteke mora biti folder", + "pathNotFound": "Putanja biblioteke nije pronađena", + "pathNotAccessible": "Putanja biblioteke nije dostupna", + "pathInvalid": "Putanja biblioteke nije validna" + }, + "messages": { + "deleteConfirm": "Da li zaista želiš obrisati ovu biblioteku? Pristup i podaci će biti uklonjeni.", + "scanInProgress": "Skeniranje biblioteke u toku...", + "noLibrariesAssigned": "Nema dodijeljenih biblioteka" + } + } + }, + "ra": { + "auth": { + "welcome1": "Hvala što ste instalirali Navidrome!", + "welcome2": "Prvo kreirajte admin korisnika", + "confirmPassword": "Potvrdi lozinku", + "buttonCreateAdmin": "Kreiraj admina", + "auth_check_error": "Prijavite se da biste nastavili", + "user_menu": "Profil", + "username": "Korisničko ime", + "password": "Lozinka", + "sign_in": "Prijava", + "sign_in_error": "Greška pri prijavi", + "logout": "Odjava", + "insightsCollectionNote": "Navidrome prikuplja anonimne statistike \nda podrži razvoj projekta. \nKliknite [ovdje] za više informacija ili da isključite \"Insights\"" + }, + "validation": { + "invalidChars": "Koristite samo slova i brojeve", + "passwordDoesNotMatch": "Lozinke se ne podudaraju", + "required": "Obavezno", + "minLength": "Mora imati najmanje %{min} znakova", + "maxLength": "Mora imati najviše %{max} znakova", + "minValue": "Mora biti najmanje %{min}", + "maxValue": "Mora biti %{max} ili manje", + "number": "Mora biti broj", + "email": "Mora biti validna e-mail adresa", + "oneOf": "Mora biti jedan od: %{options}", + "regex": "Mora odgovarati regularnom izrazu: %{pattern}", + "unique": "Mora biti jedinstveno", + "url": "Mora biti validan URL" + }, + "action": { + "add_filter": "Dodaj filter", + "add": "Dodaj", + "back": "Nazad", + "bulk_actions": "1 odabrana stavka |||| %{smart_count} odabrane stavke", + "cancel": "Otkaži", + "clear_input_value": "Obriši unos", + "clone": "Kloniraj", + "confirm": "Potvrdi", + "create": "Kreiraj", + "delete": "Obriši", + "edit": "Uredi", + "export": "Izvezi", + "list": "Lista", + "refresh": "Osvježi", + "remove_filter": "Ukloni filter", + "remove": "Ukloni", + "save": "Sačuvaj", + "search": "Pretraži", + "show": "Prikaži", + "sort": "Sortiraj", + "undo": "Poništi", + "expand": "Proširi", + "close": "Zatvori", + "open_menu": "Otvori meni", + "close_menu": "Zatvori meni", + "unselect": "Poništi odabir", + "skip": "Preskoči", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Podijeli", + "download": "Preuzmi" + }, + "boolean": { + "true": "Da", + "false": "Ne" + }, + "page": { + "create": "Kreiraj %{name}", + "dashboard": "Kontrolna tabla", + "edit": "%{name} #%{id}", + "error": "Nešto je pošlo po zlu", + "list": "%{name}", + "loading": "Učitavanje", + "not_found": "Nije pronađeno", + "show": "%{name} #%{id}", + "empty": "Još nema %{name}.", + "invite": "Želiš li dodati jednu?" + }, + "input": { + "file": { + "upload_several": "Povuci datoteke ovdje za prijenos ili klikni za odabir.", + "upload_single": "Povuci datoteku ovdje za prijenos ili klikni za odabir." + }, + "image": { + "upload_several": "Povuci slike ovdje za prijenos ili klikni za odabir.", + "upload_single": "Povuci sliku ovdje za prijenos ili klikni za odabir." + }, + "references": { + "all_missing": "Povezane reference nisu pronađene.", + "many_missing": "Neke povezane reference više nisu dostupne.", + "single_missing": "Povezana referenca više nije dostupna." + }, + "password": { + "toggle_visible": "Sakrij lozinku", + "toggle_hidden": "Prikaži lozinku" + } + }, + "message": { + "about": "O aplikaciji", + "are_you_sure": "Jesi li siguran?", + "bulk_delete_content": "Da li zaista želiš obrisati \"%{name}\"? |||| Da li zaista želiš obrisati %{smart_count} stavki?", + "bulk_delete_title": "Obriši %{name} |||| Obriši %{smart_count} %{name} stavki", + "delete_content": "Da li zaista želiš obrisati ovaj sadržaj?", + "delete_title": "Obriši %{name} #%{id}", + "details": "Detalji", + "error": "Došlo je do greške i zahtjev nije mogao biti završen.", + "invalid_form": "Formular nije validan. Provjeri unose.", + "loading": "Stranica se učitava", + "no": "Ne", + "not_found": "Stranica nije pronađena.", + "yes": "Da", + "unsaved_changes": "Neke promjene nisu sačuvane. Želiš li ih ignorisati?" + }, + "navigation": { + "no_results": "Nema rezultata", + "no_more_results": "Stranica %{page} nema sadržaja.", + "page_out_of_boundaries": "Stranica %{page} je izvan opsega", + "page_out_from_end": "Posljednja stranica", + "page_out_from_begin": "Prva stranica", + "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", + "page_rows_per_page": "Redova po stranici:", + "next": "Sljedeća", + "prev": "Prethodna", + "skip_nav": "Preskoči na sadržaj" + }, + "notification": { + "updated": "Stavka ažurirana |||| %{smart_count} stavki ažurirano", + "created": "Stavka kreirana", + "deleted": "Stavka obrisana |||| %{smart_count} stavki obrisano", + "bad_item": "Neispravna stavka", + "item_doesnt_exist": "Stavka ne postoji", + "http_error": "Greška u komunikaciji sa serverom", + "data_provider_error": "Greška u dataProvider-u. Provjeri konzolu za detalje.", + "i18n_error": "Prijevod za odabrani jezik nije dostupan", + "canceled": "Akcija otkazana", + "logged_out": "Sesija je istekla. Ponovo se prijavi.", + "new_version": "Nova verzija dostupna! Osveži stranicu." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Odaberi kolone", + "layout": "Izgled", + "grid": "Mreža", + "table": "Tabela" + } + }, + "message": { + "note": "NAPOMENA", + "transcodingDisabled": "Izmjena postavki transkodiranja preko web sučelja je onemogućena iz sigurnosnih razloga. Ako želiš promijeniti opcije transkodiranja (urediti ili dodati), ponovo pokreni server sa konfiguracijskom opcijom %{config}.", + "transcodingEnabled": "Navidrome trenutno radi sa %{config}, što omogućava izvršavanje sistemskih komandi kroz postavke transkodiranja preko web sučelja. Preporučujemo da ovo onemogućiš iz sigurnosnih razloga i koristiš samo prilikom konfiguracije transkodiranja.", + "songsAddedToPlaylist": "1 pjesma dodana u playlistu |||| %{smart_count} pjesme dodane u playlistu", + "noPlaylistsAvailable": "Nema dostupnih playlisti", + "delete_user_title": "Obriši korisnika '%{name}'", + "delete_user_content": "Da li zaista želiš obrisati ovog korisnika i sve njegove podatke (uključujući playliste i postavke)?", + "notifications_blocked": "Blokirali ste obavijesti za ovu stranicu u postavkama preglednika", + "notifications_not_available": "Ovaj preglednik ne podržava desktop obavijesti", + "lastfmLinkSuccess": "Last.fm veza uspostavljena i scrobbling omogućen", + "lastfmLinkFailure": "Last.fm veza nije uspjela", + "lastfmUnlinkSuccess": "Last.fm veza uklonjena i scrobbling onemogućen", + "lastfmUnlinkFailure": "Last.fm veza nije uklonjena", + "openIn": { + "lastfm": "Prikaži na Last.fm", + "musicbrainz": "Prikaži na MusicBrainz" + }, + "lastfmLink": "Pročitaj više", + "listenBrainzLinkSuccess": "ListenBrainz veza uspostavljena i scrobbling omogućen kao korisnik: %{user}", + "listenBrainzLinkFailure": "ListenBrainz veza nije uspjela: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz veza uklonjena i scrobbling onemogućen", + "listenBrainzUnlinkFailure": "ListenBrainz veza nije uklonjena", + "downloadOriginalFormat": "Preuzmi u originalnom formatu", + "shareOriginalFormat": "Podijeli u originalnom formatu", + "shareDialogTitle": "Podijeli %{resource} '%{name}'", + "shareBatchDialogTitle": "Podijeli 1 %{resource} |||| Podijeli %{smart_count} %{resource}", + "shareSuccess": "URL kopiran u međuspremnik: %{url}", + "shareFailure": "Greška pri kopiranju URL-a %{url} u međuspremnik", + "downloadDialogTitle": "Preuzmi %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopiraj u međuspremnik: Ctrl+C, Enter", + "remove_missing_title": "Ukloni nedostajuće datoteke", + "remove_missing_content": "Da li zaista želiš ukloniti odabrane nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.", + "remove_all_missing_title": "Ukloni sve nedostajuće datoteke", + "remove_all_missing_content": "Da li zaista želiš ukloniti sve nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.", + "noSimilarSongsFound": "Nema sličnih pjesama", + "noTopSongsFound": "Nema popularnih pjesama" + }, + "menu": { + "library": "Biblioteka", + "settings": "Postavke", + "version": "Verzija", + "theme": "Tema", + "personal": { + "name": "Lično", + "options": { + "theme": "Tema", + "language": "Jezik", + "defaultView": "Zadani pregled", + "desktop_notifications": "Desktop obavijesti", + "lastfmScrobbling": "Last.fm scrobbling", + "listenBrainzScrobbling": "ListenBrainz scrobbling", + "replaygain": "ReplayGain mod", + "preAmp": "ReplayGain pojačanje (dB)", + "gain": { + "none": "Isključeno", + "album": "Koristi album gain", + "track": "Koristi pjesmu gain" + }, + "lastfmNotConfigured": "Last.fm API ključ nije konfiguriran" + } + }, + "albumList": "Albumi", + "about": "O aplikaciji", + "playlists": "Playliste", + "sharedPlaylists": "Dijeljene playliste", + "librarySelector": { + "allLibraries": "Sve biblioteke (%{count})", + "multipleLibraries": "%{selected} od %{total} biblioteka", + "selectLibraries": "Odaberi biblioteke", + "none": "Nijedna" + } + }, + "player": { + "playListsText": "Reprodukcija reda čekanja", + "openText": "Otvori", + "closeText": "Zatvori", + "notContentText": "Nema muzike", + "clickToPlayText": "Klikni za reprodukciju", + "clickToPauseText": "Klikni za pauzu", + "nextTrackText": "Sljedeća pjesma", + "previousTrackText": "Prethodna pjesma", + "reloadText": "Ponovo učitaj", + "volumeText": "Glasnoća", + "toggleLyricText": "Prikaži/sakrij tekst", + "toggleMiniModeText": "Minimiziraj", + "destroyText": "Uništi", + "downloadText": "Preuzmi", + "removeAudioListsText": "Ukloni audio liste", + "clickToDeleteText": "Klikni za brisanje %{name}", + "emptyLyricText": "Nema teksta", + "playModeText": { + "order": "Redom", + "orderLoop": "Ponavljaj", + "singleLoop": "Ponavljaj jednu", + "shufflePlay": "Nasumična reprodukcija" + } + }, + "about": { + "links": { + "homepage": "Početna stranica", + "source": "Izvorni kod", + "featureRequests": "Zahtjevi za funkcijama", + "lastInsightsCollection": "Posljednje prikupljanje \"Insights\"", + "insights": { + "disabled": "Isključeno", + "waiting": "Čekanje" + } + }, + "tabs": { + "about": "O aplikaciji", + "config": "Konfiguracija" + }, + "config": { + "configName": "Postavka", + "environmentVariable": "Varijabla okruženja", + "currentValue": "Vrijednost", + "configurationFile": "Konfiguracijska datoteka", + "exportToml": "Izvezi konfiguraciju (TOML)", + "exportSuccess": "Konfiguracija kopirana u međuspremnik u TOML formatu", + "exportFailed": "Greška pri kopiranju konfiguracije", + "devFlagsHeader": "Dev postavke (mogu se promijeniti)", + "devFlagsComment": "Eksperimentalne postavke koje mogu biti uklonjene ili promijenjene u budućnosti" + } + }, + "activity": { + "title": "Aktivnost", + "totalScanned": "Ukupno skeniranih foldera", + "quickScan": "Brzo skeniranje", + "fullScan": "Potpuno skeniranje", + "serverUptime": "Vrijeme rada servera", + "serverDown": "ISKLJUČEN", + "scanType": "Tip", + "status": "Greška pri skeniranju", + "elapsedTime": "Proteklo vrijeme" + }, + "help": { + "title": "Navidrome prečice", + "hotkeys": { + "show_help": "Prikaži ovu pomoć", + "toggle_menu": "Uključi/isključi bočnu traku", + "toggle_play": "Reprodukcija / Pauza", + "prev_song": "Prethodna pjesma", + "next_song": "Sljedeća pjesma", + "vol_up": "Glasnije", + "vol_down": "Tiše", + "toggle_love": "Dodaj u favorite", + "current_song": "Prikaži trenutnu pjesmu" + } + }, + "nowPlaying": { + "title": "Trenutna reprodukcija", + "empty": "Nema reprodukcije", + "minutesAgo": "Prije %{smart_count} minute |||| Prije %{smart_count} minuta" + } +} From 949bff993e2bbde81cd0f88f01d1ab5bd1985712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Br=C3=BCckner?= <mb+github@gekrumbel.de> Date: Tue, 29 Jul 2025 18:06:29 +0200 Subject: [PATCH 37/64] fix(ui): update Deutsch, Galego, Italiano translations (#4394) --- resources/i18n/de.json | 2 +- resources/i18n/gl.json | 2 +- resources/i18n/it.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index cd2e47acd..c9c7fa7f5 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -273,7 +273,7 @@ "empty": "keine fehlenden Dateien" }, "library": { - "name": "Bibliothek ||| Bibliotheken", + "name": "Bibliothek |||| Bibliotheken", "fields": { "name": "Name", "path": "Pfad", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 4d9a1a9a0..8cde597cc 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -63,7 +63,7 @@ "size": "Tamaño", "originalDate": "Orixinal", "releaseDate": "Publicado", - "releases": "Publicación ||| Publicacións", + "releases": "Publicación |||| Publicacións", "released": "Publicado", "recordLabel": "Editorial", "catalogNum": "Número de catálogo", diff --git a/resources/i18n/it.json b/resources/i18n/it.json index aaaa2f8c2..9d1c2bb74 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -232,7 +232,7 @@ "add_filter": "Aggiungi un filtro", "add": "Aggiungi", "back": "Indietro", - "bulk_actions": "Un elemento selezionato ||| %{smart_count} elementi selezionati", + "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", From 94d2696c8434171f1b67f4645be0c6914fe34c19 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 29 Jul 2025 17:59:58 -0400 Subject: [PATCH 38/64] feat(subsonic): populate Folder field with user's accessible library IDs Added functionality to populate the Folder field in GetUser and GetUsers API responses with the library IDs that the user has access to. This allows Subsonic API clients to understand which music folders (libraries) a user can access for proper content filtering and UI presentation. Signed-off-by: Deluan <deluan@navidrome.org> --- server/subsonic/users.go | 3 ++- server/subsonic/users_test.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 39214eee2..733f3fddb 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/slice" ) // buildUserResponse creates a User response object from a User model @@ -19,6 +20,7 @@ func buildUserResponse(user model.User) responses.User { ScrobblingEnabled: true, DownloadRole: conf.Server.EnableDownloads, ShareRole: conf.Server.EnableSharing, + Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), } if conf.Server.Jukebox.Enabled { @@ -28,7 +30,6 @@ func buildUserResponse(user model.User) responses.User { return userResponse } -// TODO This is a placeholder. The real one has to read this info from a config file or the database func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { loggedUser, ok := request.UserFrom(r.Context()) if !ok { diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go index d08462290..e41c1af63 100644 --- a/server/subsonic/users_test.go +++ b/server/subsonic/users_test.go @@ -36,6 +36,12 @@ var _ = Describe("Users", func() { conf.Server.EnableSharing = true conf.Server.Jukebox.Enabled = false + // Set up user with libraries + testUser.Libraries = model.Libraries{ + {ID: 10, Name: "Music"}, + {ID: 20, Name: "Podcasts"}, + } + // Create request with user in context req := httptest.NewRequest("GET", "/rest/getUser", nil) ctx := request.WithUser(context.Background(), testUser) @@ -57,6 +63,7 @@ var _ = Describe("Users", func() { Expect(userResponse.User.ScrobblingEnabled).To(BeTrue()) Expect(userResponse.User.DownloadRole).To(BeTrue()) Expect(userResponse.User.ShareRole).To(BeTrue()) + Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20))) // Verify GetUsers response structure Expect(usersResponse.Status).To(Equal(responses.StatusOK)) @@ -75,6 +82,7 @@ var _ = Describe("Users", func() { Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole)) Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole)) Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole)) + Expect(singleUser.Folder).To(Equal(userFromList.Folder)) }) }) @@ -93,4 +101,19 @@ var _ = Describe("Users", func() { Entry("jukebox enabled, admin-only, regular user", true, true, false, false), Entry("jukebox enabled, admin-only, admin user", true, true, true, true), ) + + Describe("Folder list population", func() { + It("should populate Folder field with user's accessible library IDs", func() { + testUser.Libraries = model.Libraries{ + {ID: 1, Name: "Music"}, + {ID: 2, Name: "Podcasts"}, + {ID: 5, Name: "Audiobooks"}, + } + + response := buildUserResponse(testUser) + + Expect(response.Folder).To(HaveLen(3)) + Expect(response.Folder).To(ContainElements(int32(1), int32(2), int32(5))) + }) + }) }) From aff9c7120ba4d543cead98181922c52a88fb800c Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 29 Jul 2025 20:35:40 -0400 Subject: [PATCH 39/64] feat(ui): add Genre column as optional field in playlist table view Added genre as a toggleable column in the playlist songs table. The Genre column displays genre information for each song in playlists and is available through the column toggle menu but disabled by default. Implements feature request from GitHub discussion #4400. Signed-off-by: Deluan <deluan@navidrome.org> --- ui/src/playlist/PlaylistSongs.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index 4292562ab..bbe38b4d5 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -169,6 +169,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { quality: isDesktop && <QualityInfo source="quality" sortable={false} />, channels: isDesktop && <NumberField source="channels" />, bpm: isDesktop && <NumberField source="bpm" />, + genre: <TextField source="genre" />, rating: config.enableStarRating && ( <RatingField source="rating" @@ -190,6 +191,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { 'playCount', 'playDate', 'albumArtist', + 'genre', 'rating', ], }) From c2657e0adb6bb904eb37bb18afa99424e3480d17 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 30 Jul 2025 17:47:46 -0400 Subject: [PATCH 40/64] chore: add `make stop` target to terminate development servers Signed-off-by: Deluan <deluan@navidrome.org> --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Makefile b/Makefile index 034015740..e30c9a32f 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,14 @@ server: check_go_env buildjs ##@Development Start the backend in development mod @ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf .PHONY: server +stop: ##@Development Stop development servers (UI and backend) + @echo "Stopping development servers..." + @-pkill -f "vite" + @-pkill -f "go tool reflex.*reflex.conf" + @-pkill -f "go run.*netgo" + @echo "Development servers stopped." +.PHONY: stop + watch: ##@Development Start Go tests in watch mode (re-run when code changes) go tool ginkgo watch -tags=netgo -notify ./... .PHONY: watch From 871ee730cd143910319d4e04403aeab5d981617d Mon Sep 17 00:00:00 2001 From: yanggqi <44476296+yanggqi@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:18:06 +0800 Subject: [PATCH 41/64] fix(ui): update Chinese simplified translation (#4403) * Update zh-Hans.json Updated Chinese translation * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update zh-Hans.json * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update resources/i18n/zh-Hans.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/i18n/zh-Hans.json | 137 ++++++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 12 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index c447f7d72..cde28c4f3 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -13,12 +13,14 @@ "album": "专辑", "path": "文件路径", "genre": "流派", + "libraryName": "媒体库", "compilation": "合辑", "year": "发行年份", "size": "文件大小", "updatedAt": "更新于", "bitRate": "比特率", "bitDepth": "比特深度", + "sampleRate": "采样率", "channels": "声道", "discSubtitle": "字幕", "starred": "收藏", @@ -33,12 +35,14 @@ "participants": "其他参与人员", "tags": "附加标签", "mappedTags": "映射标签", - "rawTags": "原始标签" + "rawTags": "原始标签", + "missing": "缺失" }, "actions": { "addToQueue": "加入播放列表", "playNow": "立即播放", "addToPlaylist": "加入歌单", + "showInPlaylist": "定位到播放列表", "shuffleAll": "全部随机播放", "download": "下载", "playNext": "下一首播放", @@ -56,6 +60,7 @@ "size": "文件大小", "name": "名称", "genre": "流派", + "libraryName": "媒体库", "compilation": "合辑", "year": "发行年份", "date": "录制日期", @@ -72,7 +77,8 @@ "releaseType": "发行类型", "grouping": "分组", "media": "媒体类型", - "mood": "情绪" + "mood": "情绪", + "missing": "缺失" }, "actions": { "playAll": "立即播放", @@ -104,7 +110,8 @@ "playCount": "播放次数", "rating": "评分", "genre": "流派", - "role": "参与角色" + "role": "参与角色", + "missing": "缺失" }, "roles": { "albumartist": "专辑歌手", @@ -119,7 +126,13 @@ "mixer": "混音师", "remixer": "重混师", "djmixer": "DJ混音师", - "performer": "演奏家" + "performer": "演奏家", + "maincredit": "主要艺术家" + }, + "actions": { + "topSongs": "热门歌曲", + "shuffle": "随机播放", + "radio": "电台" } }, "user": { @@ -136,19 +149,26 @@ "changePassword": "修改密码?", "currentPassword": "当前密码", "newPassword": "新密码", - "token": "令牌" + "token": "令牌", + "libraries": "媒体库" }, "helperTexts": { - "name": "名称的更改将在下次登录时生效" + "name": "名称的更改将在下次登录时生效", + "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库" }, "notifications": { "created": "用户已创建", "updated": "用户已更新", "deleted": "用户已删除" }, + "validation": { + "librariesRequired": "普通用户必须至少选择一个媒体库" + }, "message": { "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", - "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌" + "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌", + "selectAllLibraries": "选择全部媒体库", + "adminAutoLibraries": "管理员默认可访问所有媒体库" } }, "player": { @@ -191,12 +211,18 @@ "selectPlaylist": "选择歌单", "addNewPlaylist": "新建 %{name}", "export": "导出", + "saveQueue": "保存为歌单", "makePublic": "设为公开", - "makePrivate": "设为私有" + "makePrivate": "设为私有", + "searchOrCreate": "搜索歌单,或输入名称新建…", + "pressEnterToCreate": "按 Enter 键新建歌单", + "removeFromSelection": "移除选中项" }, "message": { "duplicate_song": "添加重复的歌曲", - "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?" + "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?", + "noPlaylistsFound": "未找到歌单", + "noPlaylists": "暂无可用歌单" } }, "radio": { @@ -237,14 +263,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": { @@ -397,11 +477,15 @@ "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。", "songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单", + "noSimilarSongsFound": "未找到相似歌曲", + "noTopSongsFound": "未找到热门歌曲", "noPlaylistsAvailable": "没有有效的歌单", "delete_user_title": "删除用户 %{name}", "delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?", "remove_missing_title": "移除丢失文件", "remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。", + "remove_all_missing_title": "删除所有丢失文件", + "remove_all_missing_content": "您确定要从数据库中删除所有丢失文件吗?这将永久删除对它们的所有引用,包括它们的播放次数和评分。", "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", "notifications_not_available": "此浏览器不支持桌面通知", "lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录", @@ -428,6 +512,12 @@ }, "menu": { "library": "曲库", + "librarySelector": { + "allLibraries": "全部媒体库 (%{count})", + "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库", + "selectLibraries": "选择媒体库", + "none": "无" + }, "settings": "设置", "version": "版本", "theme": "主题", @@ -490,6 +580,21 @@ "disabled": "禁用", "waiting": "等待" } + }, + "tabs": { + "about": "关于", + "config": "配置" + }, + "config": { + "configName": "配置名称", + "environmentVariable": "环境变量", + "currentValue": "当前值", + "configurationFile": "配置文件", + "exportToml": "导出配置(TOML)", + "exportSuccess": "配置以 TOML 格式导出到剪贴板", + "exportFailed": "复制配置失败", + "devFlagsHeader": "开发标志(可能会更改/删除)", + "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除" } }, "activity": { @@ -498,7 +603,15 @@ "quickScan": "快速扫描", "fullScan": "完全扫描", "serverUptime": "服务器已运行", - "serverDown": "服务器已离线" + "serverDown": "服务器已离线", + "scanType": "扫描类型", + "status": "扫描状态", + "elapsedTime": "用时" + }, + "nowPlaying": { + "title": "正在播放", + "empty": "无播放内容", + "minutesAgo": "%{smart_count} 分钟前" }, "help": { "title": "Navidrome 快捷键", @@ -514,4 +627,4 @@ "toggle_love": "添加/移除星标" } } -} \ No newline at end of file +} From b2019da9999165dee92d1a9ecf6c1c1034c197db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 25 Oct 2025 17:05:16 -0400 Subject: [PATCH 42/64] chore(deps): update all dependencies (#4618) * chore: update to Go 1.25.3 Signed-off-by: Deluan <deluan@navidrome.org> * chore: update to golangci-lint Signed-off-by: Deluan <deluan@navidrome.org> * chore: update go dependencies Signed-off-by: Deluan <deluan@navidrome.org> * chore: update vite dependencies in package.json and improve EventSource mock in tests - Upgraded @vitejs/plugin-react to version 5.1.0 and @vitest/coverage-v8 to version 4.0.3. - Updated vite to version 7.1.12 and vite-plugin-pwa to version 1.1.0. - Enhanced the EventSource mock implementation in eventStream.test.js for better test isolation. * ci: remove coverage flag from Go test command in pipeline * chore: update Node.js version to v24 in devcontainer, pipeline, and .nvmrc * chore: prettier Signed-off-by: Deluan <deluan@navidrome.org> * chore: update actions/checkout from v4 to v5 in pipeline and update-translations workflows * chore: update JS dependencies remove unused jest-dom import in Linkify.test.jsx * chore: update actions/download-artifact from v4 to v5 in pipeline --------- Signed-off-by: Deluan <deluan@navidrome.org> --- .devcontainer/devcontainer.json | 4 +- .github/workflows/pipeline.yml | 32 +- .github/workflows/update-translations.yml | 2 +- .nvmrc | 2 +- Dockerfile | 2 +- Makefile | 2 +- go.mod | 71 +- go.sum | 183 ++-- scanner/walk_dir_tree_test.go | 2 +- ui/package-lock.json | 1151 +++++++-------------- ui/package.json | 26 +- ui/src/common/Linkify.test.jsx | 1 - ui/src/eventStream.test.js | 2 +- 13 files changed, 566 insertions(+), 914 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f339f62f7..ff58994db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,10 +4,10 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.24", + "VARIANT": "1.25", // Options "INSTALL_NODE": "true", - "NODE_VERSION": "v20" + "NODE_VERSION": "v24" } }, "workspaceMount": "", diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9488f20f7..232171c6d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -25,7 +25,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -63,7 +63,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -93,7 +93,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download TagLib uses: ./.github/actions/download-taglib @@ -106,7 +106,7 @@ jobs: - name: Test run: | pkg-config --define-prefix --cflags --libs taglib # for debugging - go test -shuffle=on -tags netgo -race -cover ./... -v + go test -shuffle=on -tags netgo -race ./... -v js: name: Test JS code @@ -114,10 +114,10 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -145,7 +145,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: | set -e for file in resources/i18n/*.json; do @@ -191,7 +191,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -264,10 +264,10 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: path: /tmp/digests pattern: digests-* @@ -318,9 +318,9 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: path: ./binaries pattern: navidrome-windows* @@ -352,12 +352,12 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: path: ./binaries pattern: navidrome-* @@ -406,7 +406,7 @@ jobs: item: ${{ fromJson(needs.release.outputs.package_list) }} steps: - name: Download all-packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: packages path: ./dist diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 70a9de3d8..69ca1cc94 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get updated translations id: poeditor env: diff --git a/.nvmrc b/.nvmrc index 9a2a0e219..54c65116f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v24 diff --git a/Dockerfile b/Dockerfile index ec3b6d938..eeb270e00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace diff --git a/Makefile b/Makefile index e30c9a32f..a4ba45ae0 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ test-i18n: ##@Development Validate all translations files .PHONY: test-i18n install-golangci-lint: ##@Development Install golangci-lint if not present - @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6) + @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.5.0) .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code diff --git a/go.mod b/go.mod index e1a827f1d..265cbfa6d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.24.5 +go 1.25.3 // Fork to fix https://github.com/navidrome/navidrome/pull/3254 replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d @@ -9,7 +9,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/RaveNoX/go-jsoncommentstrip v1.0.0 github.com/andybalholm/cascadia v1.3.3 - github.com/bmatcuk/doublestar/v4 v4.9.0 + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -22,15 +22,15 @@ require ( github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 - github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-viper/encoding/ini v0.1.1 - github.com/gohugoio/hashstructure v0.5.0 + github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 - github.com/google/wire v0.6.0 + github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.0 @@ -40,39 +40,40 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.29 + github.com/mattn/go-sqlite3 v1.14.32 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.38.0 + github.com/onsi/ginkgo/v2 v2.27.1 + github.com/onsi/gomega v1.38.2 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pocketbase/dbx v1.11.0 - github.com/pressly/goose/v3 v3.24.3 - github.com/prometheus/client_golang v1.22.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/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/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 - golang.org/x/image v0.29.0 - golang.org/x/net v0.42.0 - golang.org/x/sync v0.16.0 - golang.org/x/sys v0.34.0 - golang.org/x/text v0.27.0 - golang.org/x/time v0.12.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 + golang.org/x/image v0.32.0 + golang.org/x/net v0.46.0 + golang.org/x/sync v0.17.0 + golang.org/x/sys v0.37.0 + golang.org/x/text v0.30.0 + golang.org/x/time v0.14.0 + google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -86,9 +87,9 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.17.1 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -108,27 +109,29 @@ require ( github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect - github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/pflag v1.0.7 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/tools v0.35.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect + golang.org/x/tools v0.38.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 36558f264..f9e620fb2 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= @@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= -github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= @@ -60,8 +62,14 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -71,8 +79,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= @@ -81,25 +89,24 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= -github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= -github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -115,6 +122,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= @@ -153,14 +162,18 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= -github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= @@ -173,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= -github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= +github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s= +github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -190,14 +203,14 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= -github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -212,12 +225,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= -github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= -github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -229,19 +242,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -252,12 +263,20 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= @@ -273,28 +292,30 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= -golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -303,12 +324,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -316,8 +336,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -331,19 +351,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= @@ -357,24 +377,23 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -386,11 +405,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= -modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index c4278ef82..1cab8a0b7 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -42,7 +42,7 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f2.mp3": {}, "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, - "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")}, + "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, }, } job = &scanJob{ diff --git a/ui/package-lock.json b/ui/package-lock.json index 9e449c5e0..e9161739f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", "clsx": "^2.1.1", @@ -37,8 +37,8 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.3.0", - "uuid": "^11.1.0", + "redux-saga": "^1.4.2", + "uuid": "^13.0.0", "workbox-cli": "^7.3.0" }, "devDependencies": { @@ -46,46 +46,35 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.15.21", - "@types/react": "^17.0.86", + "@types/node": "^24.9.1", + "@types/react": "^17.0.89", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.5.0", - "@vitest/coverage-v8": "^3.1.4", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.3", "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.5", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-refresh": "^0.4.24", "happy-dom": "^17.4.7", "jsdom": "^26.1.0", - "prettier": "^3.5.3", + "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^6.3.5", - "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.1.4" + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", - "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", - "dev": true - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", @@ -128,20 +117,20 @@ } }, "node_modules/@babel/core": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", - "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helpers": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -165,14 +154,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -299,6 +288,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -324,13 +321,13 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -411,9 +408,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "engines": { "node": ">=6.9.0" } @@ -440,23 +437,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1464,9 +1461,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -1497,29 +1495,29 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2214,76 +2212,6 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/types": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", @@ -2301,16 +2229,21 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2321,14 +2254,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", @@ -2339,14 +2264,14 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2596,16 +2521,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -2630,16 +2545,17 @@ } }, "node_modules/@redux-saga/core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.3.0.tgz", - "integrity": "sha512-L+i+qIGuyWn7CIg7k1MteHGfttKPmxwZR5E7OsGikCL2LzYA0RERlaUY00Y3P3ZV2EYgrsYlBrGs6cJP5OKKqA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", + "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.6.3", - "@redux-saga/deferred": "^1.2.1", - "@redux-saga/delay-p": "^1.2.1", - "@redux-saga/is": "^1.1.3", - "@redux-saga/symbols": "^1.1.3", - "@redux-saga/types": "^1.2.1", + "@babel/runtime": "^7.28.4", + "@redux-saga/deferred": "^1.3.1", + "@redux-saga/delay-p": "^1.3.1", + "@redux-saga/is": "^1.2.1", + "@redux-saga/symbols": "^1.2.1", + "@redux-saga/types": "^1.3.1", "typescript-tuple": "^2.2.1" }, "funding": { @@ -2648,41 +2564,46 @@ } }, "node_modules/@redux-saga/deferred": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.2.1.tgz", - "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz", + "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==", + "license": "MIT" }, "node_modules/@redux-saga/delay-p": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.2.1.tgz", - "integrity": "sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", + "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", + "license": "MIT", "dependencies": { - "@redux-saga/symbols": "^1.1.3" + "@redux-saga/symbols": "^1.2.1" } }, "node_modules/@redux-saga/is": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.3.tgz", - "integrity": "sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", + "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", + "license": "MIT", "dependencies": { - "@redux-saga/symbols": "^1.1.3", - "@redux-saga/types": "^1.2.1" + "@redux-saga/symbols": "^1.2.1", + "@redux-saga/types": "^1.3.1" } }, "node_modules/@redux-saga/symbols": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.3.tgz", - "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", + "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", + "license": "MIT" }, "node_modules/@redux-saga/types": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz", - "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", + "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", + "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", "dev": true }, "node_modules/@rollup/plugin-node-resolve": { @@ -2793,6 +2714,12 @@ "node": ">=6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -2844,17 +2771,17 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", - "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", + "picocolors": "^1.1.1", "redent": "^3.0.0" }, "engines": { @@ -2863,24 +2790,12 @@ "yarn": ">=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@testing-library/react": { "version": "12.1.5", @@ -3017,6 +2932,22 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3067,12 +2998,13 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/normalize-package-data": { @@ -3086,9 +3018,10 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "17.0.86", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.86.tgz", - "integrity": "sha512-lPFuSjA85jecet6D4ZsPvCFuSrz6g2hkTSUw8MM0x5z2EndPV/itGnYQ39abjxd7F+cAcxLGtKQjnLn9cNUz3g==", + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3370,50 +3303,49 @@ "dev": true }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", - "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", "dev": true, "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz", - "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz", + "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "debug": "^4.4.0", + "@vitest/utils": "4.0.3", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", + "istanbul-reports": "^3.2.0", "magicast": "^0.3.5", "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.4", - "vitest": "3.1.4" + "@vitest/browser": "4.0.3", + "vitest": "4.0.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3422,36 +3354,38 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", - "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz", + "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==", "dev": true, "dependencies": { - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", - "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz", + "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==", "dev": true, "dependencies": { - "@vitest/spy": "3.1.4", + "@vitest/spy": "4.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.19" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -3463,24 +3397,24 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", - "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz", + "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==", "dev": true, "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", - "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz", + "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==", "dev": true, "dependencies": { - "@vitest/utils": "3.1.4", + "@vitest/utils": "4.0.3", "pathe": "^2.0.3" }, "funding": { @@ -3488,13 +3422,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", - "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz", + "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.1.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.0.3", + "magic-string": "^0.30.19", "pathe": "^2.0.3" }, "funding": { @@ -3502,26 +3436,22 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", - "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz", + "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==", "dev": true, - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", - "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz", + "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.1.4", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.0.3", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3813,6 +3743,23 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4104,15 +4051,6 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", @@ -4262,19 +4200,12 @@ ] }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -4297,15 +4228,6 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4590,7 +4512,8 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cssstyle": { "version": "4.3.1", @@ -4692,9 +4615,9 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, @@ -4763,15 +4686,6 @@ "node": ">=4" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -5014,12 +4928,6 @@ "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==" }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -5390,9 +5298,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -5510,10 +5418,11 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", "dev": true, + "license": "MIT", "peerDependencies": { "eslint": ">=8.40" } @@ -5716,9 +5625,9 @@ "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "engines": { "node": ">=12.0.0" @@ -5964,34 +5873,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7186,9 +7067,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -7215,21 +7096,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -7663,12 +7529,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true - }, "node_modules/lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -7695,12 +7555,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -7865,15 +7725,6 @@ "node": ">= 6" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8263,12 +8114,6 @@ "node": ">=8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, "node_modules/package-json/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8348,28 +8193,6 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", @@ -8393,15 +8216,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8432,9 +8246,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -8451,7 +8265,7 @@ } ], "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8477,9 +8291,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -9258,9 +9072,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "engines": { "node": ">=0.10.0" @@ -9461,11 +9275,12 @@ } }, "node_modules/redux-saga": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.3.0.tgz", - "integrity": "sha512-J9RvCeAZXSTAibFY0kGw6Iy4EdyDNW7k6Q+liwX+bsck7QVsU78zz8vpBRweEfANxnnlG/xGGeOvf6r8UXzNJQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", + "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", + "license": "MIT", "dependencies": { - "@redux-saga/core": "^1.3.0" + "@redux-saga/core": "^1.4.2" } }, "node_modules/reflect.getprototypeof": { @@ -10184,9 +9999,9 @@ "dev": true }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, "node_modules/stop-iteration-iterator": { @@ -10231,27 +10046,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -10384,19 +10178,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -10509,55 +10290,6 @@ "node": ">=10" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10592,13 +10324,13 @@ "dev": true }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -10608,10 +10340,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -10622,9 +10357,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "engines": { "node": ">=12" @@ -10633,28 +10368,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "engines": { "node": ">=14.0.0" @@ -10875,6 +10592,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", + "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" } @@ -10882,12 +10600,14 @@ "node_modules/typescript-logic": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", + "license": "MIT" }, "node_modules/typescript-tuple": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", + "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" } @@ -10910,10 +10630,11 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -11072,15 +10793,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validate-npm-package-license": { @@ -11098,23 +10820,23 @@ "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -11123,14 +10845,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -11171,32 +10893,10 @@ } } }, - "node_modules/vite-node": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", - "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-pwa": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", - "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz", + "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==", "dev": true, "dependencies": { "debug": "^4.3.6", @@ -11212,8 +10912,8 @@ "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@vite-pwa/assets-generator": "^0.2.6", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "workbox-build": "^7.3.0", "workbox-window": "^7.3.0" }, @@ -11224,10 +10924,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -11238,9 +10941,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "engines": { "node": ">=12" @@ -11250,38 +10953,37 @@ } }, "node_modules/vitest": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", - "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz", + "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", "dev": true, "dependencies": { - "@vitest/expect": "3.1.4", - "@vitest/mocker": "3.1.4", - "@vitest/pretty-format": "^3.1.4", - "@vitest/runner": "3.1.4", - "@vitest/snapshot": "3.1.4", - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.0.3", + "@vitest/mocker": "4.0.3", + "@vitest/pretty-format": "4.0.3", + "@vitest/runner": "4.0.3", + "@vitest/snapshot": "4.0.3", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", "pathe": "^2.0.3", + "picomatch": "^4.0.3", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.13", - "tinypool": "^1.0.2", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.4", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -11289,9 +10991,11 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.4", - "@vitest/ui": "3.1.4", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.3", + "@vitest/browser-preview": "4.0.3", + "@vitest/browser-webdriverio": "4.0.3", + "@vitest/ui": "4.0.3", "happy-dom": "*", "jsdom": "*" }, @@ -11305,7 +11009,13 @@ "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -11319,6 +11029,18 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -11868,97 +11590,6 @@ "workbox-core": "7.3.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index b9c93316b..3a39e5f32 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,7 +18,7 @@ "dependencies": { "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", "clsx": "^2.1.1", @@ -46,8 +46,8 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.3.0", - "uuid": "^11.1.0", + "redux-saga": "^1.4.2", + "uuid": "^13.0.0", "workbox-cli": "^7.3.0" }, "devDependencies": { @@ -55,27 +55,27 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.15.21", - "@types/react": "^17.0.86", + "@types/node": "^24.9.1", + "@types/react": "^17.0.89", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.5.0", - "@vitest/coverage-v8": "^3.1.4", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.3", "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.5", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-refresh": "^0.4.24", "happy-dom": "^17.4.7", "jsdom": "^26.1.0", - "prettier": "^3.5.3", + "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^6.3.5", - "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.1.4" + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" }, "overrides": { "vite": { diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.jsx index cef50b228..cd19ffa03 100644 --- a/ui/src/common/Linkify.test.jsx +++ b/ui/src/common/Linkify.test.jsx @@ -1,6 +1,5 @@ import React from 'react' import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' import Linkify from './Linkify' const URL = 'http://www.example.com' diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js index 5bd0dd0be..27f53c872 100644 --- a/ui/src/eventStream.test.js +++ b/ui/src/eventStream.test.js @@ -25,7 +25,7 @@ describe('startEventStream', () => { beforeEach(() => { dispatch = vi.fn() - global.EventSource = vi.fn((url) => { + global.EventSource = vi.fn().mockImplementation(function (url) { instance = new MockEventSource(url) return instance }) From ac3e6ae6a5a0548abf3a648295faea87761ea83e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:24:31 -0400 Subject: [PATCH 43/64] chore(deps-dev): bump brace-expansion from 1.1.11 to 1.1.12 in /ui (#4217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- ui/package-lock.json | 56 +++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index e9161739f..89a6589e6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2098,10 +2098,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2171,10 +2172,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3974,9 +3976,10 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -5352,10 +5355,11 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5428,10 +5432,11 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5499,10 +5504,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6053,9 +6059,10 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7114,9 +7121,10 @@ } }, "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From e24f7984cc9018c59dc79adecc8c788bd7291d80 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 25 Oct 2025 17:25:48 -0400 Subject: [PATCH 44/64] chore(deps-dev): update happy-dom to version 20.0.8 Signed-off-by: Deluan <deluan@navidrome.org> --- ui/package-lock.json | 38 ++++++++++++++++++++++++++++++++------ ui/package.json | 2 +- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 89a6589e6..c0901a73d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -59,7 +59,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", - "happy-dom": "^17.4.7", + "happy-dom": "^20.0.8", "jsdom": "^26.1.0", "prettier": "^3.6.2", "ra-test": "^3.19.12", @@ -3093,6 +3093,13 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", @@ -6180,18 +6187,37 @@ "dev": true }, "node_modules/happy-dom": { - "version": "17.4.7", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.7.tgz", - "integrity": "sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==", + "version": "20.0.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz", + "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==", "dev": true, + "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", + "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index 3a39e5f32..a3612aaf4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -68,7 +68,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", - "happy-dom": "^17.4.7", + "happy-dom": "^20.0.8", "jsdom": "^26.1.0", "prettier": "^3.6.2", "ra-test": "^3.19.12", From 925bfafc1f4d0f527004031f923c224bb70166a3 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Sat, 25 Oct 2025 17:42:33 -0400 Subject: [PATCH 45/64] build: enhance golangci-lint installation process to check version and reinstall if necessary --- Makefile | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a4ba45ae0..df8155f56 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop # Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib CROSS_TAGLIB_VERSION ?= 2.1.1-1 +GOLANGCI_LINT_VERSION ?= v2.5.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -65,7 +66,22 @@ test-i18n: ##@Development Validate all translations files .PHONY: test-i18n install-golangci-lint: ##@Development Install golangci-lint if not present - @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.5.0) + @INSTALL=false; \ + if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \ + if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \ + echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \ + rm -f ./bin/golangci-lint; \ + INSTALL=true; \ + fi; \ + else \ + INSTALL=true; \ + fi; \ + if [ "$$INSTALL" = "true" ]; then \ + echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \ + fi .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code From aa7f55646dec28423913a4e6688bd001086edf14 Mon Sep 17 00:00:00 2001 From: Daniele Ricci <daniele@casaricci.it> Date: Sat, 25 Oct 2025 23:47:09 +0200 Subject: [PATCH 46/64] build(docker): use standalone wget instead of the busybox one, fix #4473 wget in busybox doesn't support redirects (required for downloading artifacts from GitHub) --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index eeb270e00..fb1cf997b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ ARG TARGETPLATFORM ARG CROSS_TAGLIB_VERSION=2.1.1-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ +# wget in busybox can't follow redirects RUN <<EOT + apk add --no-cache wget PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-') FILE=taglib-${PLATFORM}.tar.gz From d02128927975971db1462e577357f728b9d7911c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 26 Oct 2025 19:36:44 -0400 Subject: [PATCH 47/64] fix: enable multi-valued releasetype in smart playlists (#4621) * fix: prevent infinite loop in Type filter autocomplete Fixed an infinite loop issue in the album Type filter caused by an inline arrow function in the optionText prop. The inline function created a new reference on every render, causing React-Admin's AutocompleteInput to continuously re-fetch data from the /api/tag endpoint. The solution extracts the formatting function outside the component scope as formatReleaseType, ensuring a stable function reference across renders. This prevents unnecessary re-renders and API calls while maintaining the humanized display format for release type values. * fix: enable multi-valued releasetype in smart playlists Smart playlists can now match all values in multi-valued releasetype tags. Previously, the albumtype field was mapped to the single-valued mbz_album_type database field, which only stored the first value from tags like album; soundtrack. This prevented smart playlists from matching albums with secondary release types like soundtrack, live, or compilation when tagged by MusicBrainz Picard. The fix removes the direct database field mapping and allows both albumtype and releasetype to use the multi-valued tag system. The albumtype field is now an alias that points to the releasetype tag field, ensuring both query the same JSON path in the tags column. This maintains backward compatibility with the documented albumtype field while enabling proper multi-value tag matching. Added tests to verify both releasetype and albumtype correctly generate multi-valued tag queries. Fixes #4616 * fix: resolve albumtype alias for all operators and sorting Codex correctly identified that the initial fix only worked for Contains/StartsWith/EndsWith operators. The alias resolution was happening too late in the code path. Fixed by resolving the alias in two places: 1. tagCond.ToSql() - now uses the actual field name (releasetype) in the JSON path 2. Criteria.OrderBy() - now uses the actual field name when building sort expressions Added tests for Is/IsNot operators and sorting to ensure complete coverage. --- model/criteria/criteria.go | 7 ++++++- model/criteria/criteria_test.go | 10 ++++++++++ model/criteria/fields.go | 18 ++++++++++++----- model/criteria/operators_test.go | 34 ++++++++++++++++++++++++++++++++ ui/src/album/AlbumList.jsx | 7 ++++--- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index fa92c5aca..54ac59697 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string { if f.order != "" { mapped = f.order } else if f.isTag { - mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')" + // Use the actual field name (handles aliases like albumtype -> releasetype) + tagName := sortField + if f.field != "" { + tagName = f.field + } + mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" } else if f.isRole { mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" } else { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 3792264a5..032ead5c8 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() { ) }) + It("sorts by albumtype alias (resolves to releasetype)", func() { + AddTagNames([]string{"releasetype"}) + goObj.Sort = "albumtype" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal( + "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", + ), + ) + }) + It("sorts by random", func() { newObj := goObj newObj.Sort = "random" diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 3699eb14a..70719cd6f 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{ "sortalbum": {field: "media_file.sort_album_name"}, "sortartist": {field: "media_file.sort_artist_name"}, "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"}, "albumcomment": {field: "media_file.mbz_album_comment"}, "catalognumber": {field: "media_file.catalog_num"}, "filepath": {field: "media_file.path"}, @@ -55,6 +54,9 @@ var fieldMap = map[string]*mappedField{ "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, "library_id": {field: "media_file.library_id", numeric: true}, + // Backward compatibility: albumtype is an alias for releasetype tag + "albumtype": {field: "releasetype", isTag: true}, + // special fields "random": {field: "", order: "random()"}, // pseudo-field for random sorting "value": {field: "value"}, // pseudo-field for tag and roles values @@ -154,13 +156,19 @@ type tagCond struct { func (e tagCond) ToSql() (string, []any, error) { cond, args, err := e.cond.ToSql() - // Check if this tag is marked as numeric in the fieldMap - if fm, ok := fieldMap[e.tag]; ok && fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + // Resolve the actual tag name (handles aliases like albumtype -> releasetype) + tagName := e.tag + if fm, ok := fieldMap[e.tag]; ok { + if fm.field != "" { + tagName = fm.field + } + if fm.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } } cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", - e.tag, cond) + tagName, cond) if e.not { cond = "not " + cond } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index ee716a9cd..4c1db1303 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -105,6 +105,40 @@ var _ = Describe("Operators", func() { gomega.Expect(sql).To(gomega.BeEmpty()) gomega.Expect(args).To(gomega.BeEmpty()) }) + It("supports releasetype as multi-valued tag", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"releasetype": "soundtrack"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) + }) + It("supports albumtype as alias for releasetype", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"albumtype": "live"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%live%")) + }) + It("supports albumtype alias with Is operator", func() { + AddTagNames([]string{"releasetype"}) + op := Is{"albumtype": "album"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("album")) + }) + It("supports albumtype alias with IsNot operator", func() { + AddTagNames([]string{"releasetype"}) + op := IsNot{"albumtype": "compilation"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("compilation")) + }) }) Describe("Custom Roles", func() { diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 40b927a89..f10f8dbd3 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -42,6 +42,9 @@ const useStyles = makeStyles({ }, }) +const formatReleaseType = (record) => + record?.tagValue ? humanize(record?.tagValue) : '-- None --' + const AlbumFilter = (props) => { const classes = useStyles() const translate = useTranslate() @@ -142,9 +145,7 @@ const AlbumFilter = (props) => { > <AutocompleteInput emptyText="-- None --" - optionText={(record) => - record?.tagValue ? humanize(record?.tagValue) : '-- None --' - } + optionText={formatReleaseType} /> </ReferenceInput> <NullableBooleanInput source="compilation" /> From cce11c5416f9321942748626c217a4f0d1d3a445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sun, 26 Oct 2025 19:38:34 -0400 Subject: [PATCH 48/64] fix(scanner): restore basic tag extraction fallback mechanism for improved metadata parsing (#4401) * feat: add basic tag extraction fallback mechanism Added basic tag extraction from TagLib's generic Tag interface as a fallback when PropertyMap doesn't contain standard metadata fields. This ensures that essential tags like title, artist, album, comment, genre, year, and track are always available even when they're not present in format-specific property maps. Changes include: - Extract basic tags (__title, __artist, etc.) in C++ wrapper - Add parseBasicTag function to process basic tags in Go extractor - Refactor parseProp function to be reusable across property parsing - Ensure basic tags are preferred over PropertyMap when available * feat(taglib): update tag parsing to use double underscores for properties Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/taglib/taglib.go | 57 ++++++++++++++++++++--------- adapters/taglib/taglib_wrapper.cpp | 58 +++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go index 62a949d85..d32adf4ed 100644 --- a/adapters/taglib/taglib.go +++ b/adapters/taglib/taglib.go @@ -43,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { // Parse audio properties ap := metadata.AudioProperties{} - if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 { - millis, _ := strconv.Atoi(length[0]) - if millis > 0 { - ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10) - } - delete(tags, "_lengthinmilliseconds") - } - parseProp := func(prop string, target *int) { - if value, ok := tags[prop]; ok && len(value) > 0 { - *target, _ = strconv.Atoi(value[0]) - delete(tags, prop) - } - } - parseProp("_bitrate", &ap.BitRate) - parseProp("_channels", &ap.Channels) - parseProp("_samplerate", &ap.SampleRate) - parseProp("_bitspersample", &ap.BitDepth) + ap.BitRate = parseProp(tags, "__bitrate") + ap.Channels = parseProp(tags, "__channels") + ap.SampleRate = parseProp(tags, "__samplerate") + ap.BitDepth = parseProp(tags, "__bitspersample") + length := parseProp(tags, "__lengthinmilliseconds") + ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10) + + // Extract basic tags + parseBasicTag(tags, "__title", "title") + parseBasicTag(tags, "__artist", "artist") + parseBasicTag(tags, "__album", "album") + parseBasicTag(tags, "__comment", "comment") + parseBasicTag(tags, "__genre", "genre") + parseBasicTag(tags, "__year", "year") + parseBasicTag(tags, "__track", "tracknumber") // Parse track/disc totals parseTuple := func(prop string) { @@ -107,6 +105,31 @@ var tiplMapping = map[string]string{ "DJ-mix": "djmixer", } +// parseProp parses a property from the tags map and sets it to the target integer. +// It also deletes the property from the tags map after parsing. +func parseProp(tags map[string][]string, prop string) int { + if value, ok := tags[prop]; ok && len(value) > 0 { + v, _ := strconv.Atoi(value[0]) + delete(tags, prop) + return v + } + return 0 +} + +// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map. +// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.), +// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag. +func parseBasicTag(tags map[string][]string, basicName string, tagName string) { + basicValue := tags[basicName] + if len(basicValue) == 0 { + return + } + delete(tags, basicName) + if len(tags[tagName]) == 0 { + tags[tagName] = basicValue + } +} + // parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: // // "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp index 224642c6d..2985e8f18 100644 --- a/adapters/taglib/taglib_wrapper.cpp +++ b/adapters/taglib/taglib_wrapper.cpp @@ -45,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { // Add audio properties to the tags const TagLib::AudioProperties *props(f.audioProperties()); - goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds()); - goPutInt(id, (char *)"_bitrate", props->bitrate()); - goPutInt(id, (char *)"_channels", props->channels()); - goPutInt(id, (char *)"_samplerate", props->sampleRate()); + goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds()); + goPutInt(id, (char *)"__bitrate", props->bitrate()); + goPutInt(id, (char *)"__channels", props->channels()); + goPutInt(id, (char *)"__samplerate", props->sampleRate()); + // Extract bits per sample for supported formats + int bitsPerSample = 0; if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample()); - if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample()); + bitsPerSample = apeProperties->bitsPerSample(); + else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) }) + bitsPerSample = asfProperties->bitsPerSample(); else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample()); + bitsPerSample = flacProperties->bitsPerSample(); else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample()); + bitsPerSample = mp4Properties->bitsPerSample(); else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample()); + bitsPerSample = wavePackProperties->bitsPerSample(); else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample()); + bitsPerSample = aiffProperties->bitsPerSample(); else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample()); + bitsPerSample = wavProperties->bitsPerSample(); else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) }) - goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample()); + bitsPerSample = dsfProperties->bitsPerSample(); + + if (bitsPerSample > 0) { + goPutInt(id, (char *)"__bitspersample", bitsPerSample); + } // Send all properties to the Go map TagLib::PropertyMap tags = f.file()->properties(); + // Make sure at least the basic properties are extracted + TagLib::Tag *basic = f.file()->tag(); + if (!basic->isEmpty()) { + if (!basic->title().isEmpty()) { + tags.insert("__title", basic->title()); + } + if (!basic->artist().isEmpty()) { + tags.insert("__artist", basic->artist()); + } + if (!basic->album().isEmpty()) { + tags.insert("__album", basic->album()); + } + if (!basic->comment().isEmpty()) { + tags.insert("__comment", basic->comment()); + } + if (!basic->genre().isEmpty()) { + tags.insert("__genre", basic->genre()); + } + if (basic->year() > 0) { + tags.insert("__year", TagLib::String::number(basic->year())); + } + if (basic->track() > 0) { + tags.insert("__track", TagLib::String::number(basic->track())); + } + } + TagLib::ID3v2::Tag *id3Tags = NULL; // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) From 465846c1bc66a40a24f174ab3d23cc11f59a24a4 Mon Sep 17 00:00:00 2001 From: Konstantin Morenko <konstantin-morenko@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:14:40 +0300 Subject: [PATCH 49/64] fix(ui): fix color of MuiIconButton in Gruvbox Dark theme (#4585) * Fixed color of MuiIconButton in gruvboxDark.js * Update ui/src/themes/gruvboxDark.js Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ui/src/themes/gruvboxDark.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index b576e7713..b1a2e4c90 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -40,6 +40,11 @@ export default { color: '#ebdbb2', }, }, + MuiIconButton: { + root: { + color: '#ebdbb2', + }, + }, MuiChip: { clickable: { background: '#49483e', From 0bdd3e6f8ba29acf525a2a165407090b34f542b8 Mon Sep 17 00:00:00 2001 From: deluan <deluan.quintao@mechanical-orchard.com> Date: Thu, 30 Oct 2025 16:34:31 -0400 Subject: [PATCH 50/64] fix(ui): fix Ligera theme's RaPaginationActions contrast --- ui/src/themes/ligera.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js index 824cf7e67..0ef1601a2 100644 --- a/ui/src/themes/ligera.js +++ b/ui/src/themes/ligera.js @@ -450,13 +450,21 @@ export default { }, RaPaginationActions: { button: { - backgroundColor: 'inherit', + backgroundColor: '#fff', + color: '#000', minWidth: 48, margin: '0 4px', - border: '1px solid #282828', + border: '1px solid #cccccc', '@global': { '> .MuiButton-label': { padding: 0, + color: '#656565', + '&:hover': { + color: '#fff !important', + }, + }, + '> .MuiButton-label > svg': { + color: '#656565', }, }, }, From 91fab68578d8fa3ab7a8606c421ef1e3b67d77a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Fri, 31 Oct 2025 09:07:23 -0400 Subject: [PATCH 51/64] fix: handle UTF BOM in lyrics and playlist files (#4637) * fix: handle UTF-8 BOM in lyrics and playlist files Added UTF-8 BOM (Byte Order Mark) detection and stripping for external lyrics files and playlist files. This ensures that files with BOM markers are correctly parsed and recognized as synced lyrics or valid playlists. The fix introduces a new ioutils package with UTF8Reader and UTF8ReadFile functions that automatically detect and remove UTF-8, UTF-16 LE, and UTF-16 BE BOMs. These utilities are now used when reading external lyrics and playlist files to ensure consistent parsing regardless of BOM presence. Added comprehensive tests for BOM handling in both lyrics and playlists, including test fixtures with actual BOM markers to verify correct behavior. * test: add test for UTF-16 LE encoded LRC files Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/lyrics/sources.go | 4 +- core/lyrics/sources_test.go | 34 ++++++ core/playlists.go | 6 +- core/playlists_test.go | 18 +++ tests/fixtures/bom-test.lrc | 4 + tests/fixtures/bom-utf16-test.lrc | Bin 0 -> 164 bytes tests/fixtures/playlists/bom-test-utf16.m3u | Bin 0 -> 412 bytes tests/fixtures/playlists/bom-test.m3u | 6 + utils/ioutils/ioutils.go | 33 ++++++ utils/ioutils/ioutils_test.go | 117 ++++++++++++++++++++ 10 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/bom-test.lrc create mode 100644 tests/fixtures/bom-utf16-test.lrc create mode 100644 tests/fixtures/playlists/bom-test-utf16.m3u create mode 100644 tests/fixtures/playlists/bom-test.m3u create mode 100644 utils/ioutils/ioutils.go create mode 100644 utils/ioutils/ioutils_test.go diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 6d4a4cc6f..857dc2eef 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/ioutils" ) func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { @@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( externalLyric := basePath[0:len(basePath)-len(ext)] + suffix - contents, err := os.ReadFile(externalLyric) - + contents, err := ioutils.UTF8ReadFile(externalLyric) if errors.Is(err, os.ErrNotExist) { log.Trace(ctx, "no lyrics found at path", "path", externalLyric) return nil, nil diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index e92564c00..b3d502101 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -108,5 +108,39 @@ var _ = Describe("sources", func() { }, })) }) + + It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { + // The function looks for <basePath-without-ext><suffix>, so we need to pass + // a MediaFile with .mp3 path and look for .lrc suffix + mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // The critical assertion: even with BOM, synced should be true + Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") + Expect(lyrics[0].Line).To(HaveLen(1)) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) + }) + + It("should handle UTF-16 LE encoded LRC files", func() { + mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // UTF-16 should be properly converted to UTF-8 + Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") + Expect(lyrics[0].Line).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) + Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) + }) }) }) diff --git a/core/playlists.go b/core/playlists.go index 2eebc94e7..f98179f88 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) @@ -97,12 +98,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold } defer file.Close() + reader := ioutils.UTF8Reader(file) extension := strings.ToLower(filepath.Ext(playlistFile)) switch extension { case ".nsp": - err = s.parseNSP(ctx, pls, file) + err = s.parseNSP(ctx, pls, reader) default: - err = s.parseM3U(ctx, pls, folder, file) + err = s.parseM3U(ctx, pls, folder, reader) } return pls, err } diff --git a/core/playlists_test.go b/core/playlists_test.go index 399210ac8..fb42f9c9f 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -74,6 +74,24 @@ var _ = Describe("Playlists", func() { Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) + + It("parses playlists with UTF-8 BOM marker", func() { + pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.OwnerID).To(Equal("123")) + Expect(pls.Name).To(Equal("Test Playlist")) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) + }) + + It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { + pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.OwnerID).To(Equal("123")) + Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) + }) }) Describe("NSP", func() { diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc new file mode 100644 index 000000000..223c37de0 --- /dev/null +++ b/tests/fixtures/bom-test.lrc @@ -0,0 +1,4 @@ +[00:00.00] 作曲 : 柏大輔 +NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0. +This tests BOM handling in lyrics parsing (GitHub issue #4631). +The BOM bytes are: 0xEF 0xBB 0xBF \ No newline at end of file diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc new file mode 100644 index 0000000000000000000000000000000000000000..e40ea3255fd95fe3b366e41cad0d4502ce35bd6a GIT binary patch literal 164 zcmXwxK?;K~6hvq3DYA1X(UtTDo<K_JLQoWx3Q4uMS6@DskolSUlXo63dCo(nY870s zx13rH$`w$jk5)A5i|=qFX}~*@v{}%dEYqJ=sk&LE(VjFmnzONf_H#0JAYXVTT4MLi KXw=@cfqDTdIU6DX literal 0 HcmV?d00001 diff --git a/tests/fixtures/playlists/bom-test-utf16.m3u b/tests/fixtures/playlists/bom-test-utf16.m3u new file mode 100644 index 0000000000000000000000000000000000000000..9c2e9d599c1611769335a2850e0dae32bc7d691a GIT binary patch literal 412 zcmZ9IPfG(q3`gJDPchJof-Tm9NN)<eP_SC)7W7!`+7{|6b@9)aSKrL;BKDF=GRZG5 z`T6dVaZkaN5ets!5xC{fOvYHhV8fO-y(ixtrCt-4R6O#+%G@etEA7ILoIXP?jBZp3 zeArQ|6S!7+>U+!?pVsC2jhAtvU#k~w>BPFF`LEbibh%5bBSXczJ$t*hDT<7d=2hY) zU)soAr_8dgt5`EgGiGvL@t~bBmw$Y)MbYvEW(RulUd{a`UM;tC$hnt1Ri)V>sJwS_ WH@`2#-`_mZuBGU99`G#n$jmR%nn4r* literal 0 HcmV?d00001 diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u new file mode 100644 index 000000000..f5a00806c --- /dev/null +++ b/tests/fixtures/playlists/bom-test.m3u @@ -0,0 +1,6 @@ +#EXTM3U +# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning +# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing. +#PLAYLIST:Test Playlist +#EXTINF:123,Test Artist - Test Song +test.mp3 diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go new file mode 100644 index 000000000..89d3997f3 --- /dev/null +++ b/utils/ioutils/ioutils.go @@ -0,0 +1,33 @@ +package ioutils + +import ( + "io" + "os" + + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly. +// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8. +// This is particularly useful for reading user-provided text files (like LRC lyrics, +// playlists) that may have been created on Windows, which often adds BOM markers. +// +// Reference: https://en.wikipedia.org/wiki/Byte_order_mark +func UTF8Reader(r io.Reader) io.Reader { + return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder())) +} + +// UTF8ReadFile reads the named file and returns its contents as a byte slice, +// automatically handling BOM markers. It's similar to os.ReadFile but strips +// UTF-8 BOM and converts UTF-16 encoded files to UTF-8. +func UTF8ReadFile(filename string) ([]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + reader := UTF8Reader(file) + return io.ReadAll(reader) +} diff --git a/utils/ioutils/ioutils_test.go b/utils/ioutils/ioutils_test.go new file mode 100644 index 000000000..7f5483879 --- /dev/null +++ b/utils/ioutils/ioutils_test.go @@ -0,0 +1,117 @@ +package ioutils + +import ( + "bytes" + "io" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIOUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "IO Utils Suite") +} + +var _ = Describe("UTF8Reader", func() { + Context("when reading text with UTF-8 BOM", func() { + It("strips the UTF-8 BOM marker", func() { + // UTF-8 BOM is EF BB BF + input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hello")) + }) + + It("strips UTF-8 BOM from multi-line text", func() { + // Test with the actual LRC file format + input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("[00:00.00] test")) + }) + }) + + Context("when reading text without BOM", func() { + It("passes through unchanged", func() { + input := []byte("hello world") + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hello world")) + }) + }) + + Context("when reading UTF-16 LE encoded text", func() { + It("converts to UTF-8 and strips BOM", func() { + // UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE + input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hi")) + }) + }) + + Context("when reading UTF-16 BE encoded text", func() { + It("converts to UTF-8 and strips BOM", func() { + // UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE + input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'} + reader := UTF8Reader(bytes.NewReader(input)) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("hi")) + }) + }) + + Context("when reading empty content", func() { + It("returns empty string", func() { + reader := UTF8Reader(bytes.NewReader([]byte{})) + + output, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(output)).To(Equal("")) + }) + }) +}) + +var _ = Describe("UTF8ReadFile", func() { + Context("when reading a file with UTF-8 BOM", func() { + It("strips the BOM marker", func() { + // Use the actual fixture from issue #4631 + contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc") + Expect(err).ToNot(HaveOccurred()) + + // Should NOT start with BOM + Expect(contents[0]).ToNot(Equal(byte(0xEF))) + // Should start with '[' + Expect(contents[0]).To(Equal(byte('['))) + Expect(string(contents)).To(HavePrefix("[00:00.00]")) + }) + }) + + Context("when reading a file without BOM", func() { + It("reads the file normally", func() { + contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc") + Expect(err).ToNot(HaveOccurred()) + + // Should contain the expected content + Expect(string(contents)).To(ContainSubstring("We're no strangers to love")) + }) + }) + + Context("when reading a non-existent file", func() { + It("returns an error", func() { + _, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc") + Expect(err).To(HaveOccurred()) + }) + }) +}) From 775626e037b4b7436be06167b7b8c30a38de3e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Sat, 1 Nov 2025 20:25:33 -0400 Subject: [PATCH 52/64] refactor(scanner): optimize update artist's statistics using normalized media_file_artists table (#4641) Optimized to use the normalized media_file_artists table instead of parsing JSONB Signed-off-by: Deluan <deluan@navidrome.org> --- persistence/artist_repository.go | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index a7cf9272a..6d08c27db 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -400,23 +400,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { // This now calculates per-library statistics and stores them in library_artist.stats batchUpdateStatsSQL := ` WITH artist_role_counters AS ( - SELECT jt.atom AS artist_id, + SELECT mfa.artist_id, mf.library_id, - substr( - replace(jt.path, '$.', ''), - 1, - CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0 - THEN instr(replace(jt.path, '$.', ''), '[') - 1 - ELSE length(replace(jt.path, '$.', '')) - END - ) AS role, + mfa.role, count(DISTINCT mf.album_id) AS album_count, - count(mf.id) AS count, + count(DISTINCT mf.id) AS count, sum(mf.size) AS size - FROM media_file mf - JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL - WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY jt.atom, mf.library_id, role + FROM media_file_artists mfa + JOIN media_file mf ON mfa.media_file_id = mf.id + WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders + GROUP BY mfa.artist_id, mf.library_id, mfa.role ), artist_total_counters AS ( SELECT mfa.artist_id, @@ -445,24 +438,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { ), combined_counters AS ( SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters - UNION + UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters - UNION + UNION ALL SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter ), library_artist_counters AS ( SELECT artist_id, library_id, json_group_object( - replace(role, '"', ''), + role, json_object('a', album_count, 'm', count, 's', size) ) AS counters FROM combined_counters GROUP BY artist_id, library_id ) UPDATE library_artist - SET stats = coalesce((SELECT counters FROM library_artist_counters lac - WHERE lac.artist_id = library_artist.artist_id + SET stats = coalesce((SELECT counters FROM library_artist_counters lac + WHERE lac.artist_id = library_artist.artist_id AND lac.library_id = library_artist.library_id), '{}') WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders From e86dc03619ffb8477083de23bb4daed567ef0a2c Mon Sep 17 00:00:00 2001 From: pca006132 <john.lck40@gmail.com> Date: Sun, 2 Nov 2025 08:47:03 +0800 Subject: [PATCH 53/64] fix(ui): allow scrolling in play queue by adding delay (#4562) --- ui/src/audioplayer/Player.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 05ca6ddf7..03419add3 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -127,6 +127,7 @@ const Player = () => { /> ), locale: locale(translate), + sortableOptions: { delay: 200, delayOnTouchOnly: true }, }), [gainInfo, isDesktop, playerTheme, translate, playerState.mode], ) From 0c71842b12295dabfd3e14bfb5c8175312dde5fd Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Nov 2025 12:40:44 -0500 Subject: [PATCH 54/64] chore: update Go version to 1.25.4 Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 265cbfa6d..2d760d78d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.25.3 +go 1.25.4 // Fork to fix https://github.com/navidrome/navidrome/pull/3254 replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d From c501bc6996f48a99f75fb4727ec662da9d04ee99 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Nov 2025 12:41:16 -0500 Subject: [PATCH 55/64] chore(deps): update ginkgo to version 2.27.2 Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2d760d78d..894ad8372 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f9e620fb2..917f923c9 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.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= From 0a5abfc1b192ada4c82271e8bf622887ae78fde5 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Nov 2025 12:43:35 -0500 Subject: [PATCH 56/64] chore: update actions/upload-artifact and actions/download-artifact to latest versions Signed-off-by: Deluan <deluan@navidrome.org> --- .github/workflows/pipeline.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 232171c6d..0767346fa 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 }} From 3dfaa8cca15ea7a1ff3991c7c16d87ac218739f2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Nov 2025 12:53:41 -0500 Subject: [PATCH 57/64] ci: go mod tidy Signed-off-by: Deluan <deluan@navidrome.org> --- go.mod | 1 - go.sum | 6 ------ 2 files changed, 7 deletions(-) diff --git a/go.mod b/go.mod index 894ad8372..932e4c211 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 917f923c9..97fe24b35 100644 --- a/go.sum +++ b/go.sum @@ -186,8 +186,6 @@ 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= @@ -203,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= @@ -288,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= From fe1cee0159f0228ecaf64a0a7bcc0fd137de017a Mon Sep 17 00:00:00 2001 From: beerpsi <92439990+beer-psi@users.noreply.github.com> Date: Fri, 7 Nov 2025 02:24:07 +0700 Subject: [PATCH 58/64] 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> --- core/share.go | 17 +++++++++++++++-- core/share_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/core/share.go b/core/share.go index 202c27d89..d653795ec 100644 --- a/core/share.go +++ b/core/share.go @@ -119,8 +119,21 @@ 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] + "..." + + const maxContentRunes = 30 + const truncateToRunes = 26 + + var runeCount int + var truncateIndex int + for i := range s.Contents { + runeCount++ + if runeCount == truncateToRunes+1 { + truncateIndex = i + } + } + + if runeCount > maxContentRunes { + s.Contents = s.Contents[:truncateIndex] + "..." } id, err = r.Persistable.Save(s) diff --git a/core/share_test.go b/core/share_test.go index 21069bb59..ad5a986b1 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -38,6 +38,38 @@ var _ = Describe("Share", func() { Expect(id).ToNot(BeEmpty()) Expect(entity.ID).To(Equal(id)) }) + + It("does not truncate ASCII labels shorter than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("Example Media File")) + }) + + It("truncates ASCII labels longer than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) + entity := &model.Share{Description: "test", ResourceIDs: "789"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("Example Media File But The...")) + }) + + It("does not truncate CJK labels shorter than 30 runes", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("青春コンプレックス")) + }) + + It("truncates CJK labels longer than 30 runes", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) + entity := &model.Share{Description: "test", ResourceIDs: "789"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実...")) + }) }) Describe("Update", func() { From 58b5ed86dffb91c0da71f8933c332249a3613414 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 6 Nov 2025 14:26:51 -0500 Subject: [PATCH 59/64] 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 --- core/share.go | 17 ++--------- core/share_test.go | 4 +-- utils/str/str.go | 23 +++++++++++++++ utils/str/str_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/core/share.go b/core/share.go index d653795ec..eb5e6679b 100644 --- a/core/share.go +++ b/core/share.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Share interface { @@ -120,21 +121,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { return "", model.ErrNotFound } - const maxContentRunes = 30 - const truncateToRunes = 26 - - var runeCount int - var truncateIndex int - for i := range s.Contents { - runeCount++ - if runeCount == truncateToRunes+1 { - truncateIndex = i - } - } - - if runeCount > maxContentRunes { - s.Contents = s.Contents[:truncateIndex] + "..." - } + s.Contents = str.TruncateRunes(s.Contents, 30, "...") id, err = r.Persistable.Save(s) return id, err diff --git a/core/share_test.go b/core/share_test.go index ad5a986b1..475d40ec9 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -52,7 +52,7 @@ var _ = Describe("Share", func() { 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...")) + Expect(entity.Contents).To(Equal("Example Media File But The ...")) }) It("does not truncate CJK labels shorter than 30 runes", func() { @@ -68,7 +68,7 @@ var _ = Describe("Share", func() { entity := &model.Share{Description: "test", ResourceIDs: "789"} _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実...")) + Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) }) diff --git a/utils/str/str.go b/utils/str/str.go index 8a94488de..f662473da 100644 --- a/utils/str/str.go +++ b/utils/str/str.go @@ -2,6 +2,7 @@ package str import ( "strings" + "unicode/utf8" ) var utf8ToAscii = func() *strings.Replacer { @@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string { } return list[0] } + +// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated. +// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual +// string content will be truncated to fit within the maxRunes limit including the suffix. +func TruncateRunes(s string, maxRunes int, suffix string) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + + suffixRunes := utf8.RuneCountInString(suffix) + truncateAt := maxRunes - suffixRunes + if truncateAt < 0 { + truncateAt = 0 + } + + runes := []rune(s) + if truncateAt >= len(runes) { + return s + suffix + } + + return string(runes[:truncateAt]) + suffix +} diff --git a/utils/str/str_test.go b/utils/str/str_test.go index 0c3524e4e..511805831 100644 --- a/utils/str/str_test.go +++ b/utils/str/str_test.go @@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() { Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album")) }) }) + + Describe("TruncateRunes", func() { + It("returns string unchanged if under max runes", func() { + Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello")) + }) + + It("returns string unchanged if exactly at max runes", func() { + Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello")) + }) + + It("truncates and adds suffix when over max runes", func() { + Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello...")) + }) + + It("handles unicode characters correctly", func() { + // 6 emoji characters, maxRunes=5, suffix="..." (3 runes) + // So content gets 5-3=2 runes + Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁...")) + }) + + It("handles multi-byte UTF-8 characters", func() { + // Characters like é are single runes + Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca...")) + }) + + It("works with empty suffix", func() { + Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello")) + }) + + It("accounts for suffix length in truncation", func() { + // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content + result := str.TruncateRunes("hello world this is long", 10, "...") + Expect(result).To(Equal("hello w...")) + // Verify total rune count is <= maxRunes + runeCount := len([]rune(result)) + Expect(runeCount).To(BeNumerically("<=", 10)) + }) + + It("handles very long suffix gracefully", func() { + // If suffix is longer than maxRunes, we still add it + // but the content will be truncated to 0 + result := str.TruncateRunes("hello world", 5, "... (truncated)") + // Result will be just the suffix (since truncateAt=0) + Expect(result).To(Equal("... (truncated)")) + }) + + It("handles empty string", func() { + Expect(str.TruncateRunes("", 10, "...")).To(Equal("")) + }) + + It("uses custom suffix", func() { + // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes + // "hello world" is 11 runes exactly, so we need a longer string + Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]")) + }) + + DescribeTable("truncates at rune boundaries (not byte boundaries)", + func(input string, maxRunes int, suffix string, expected string) { + Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected)) + }, + Entry("ASCII", "abcdefghij", 5, "...", "ab..."), + Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."), + Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"), + Entry("Japanese", "こんにちは世界", 3, "…", "こん…"), + ) + }) }) var testPaths = []string{ From 290a9fdeaa5f776f30fb1f0eba4419a0546e1420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 6 Nov 2025 14:34:00 -0500 Subject: [PATCH 60/64] 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 --- ui/src/utils/formatters.js | 4 ++-- ui/src/utils/formatters.test.js | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index 74cce6e15..cfcb84b05 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => { return new Date(date).toLocaleDateString(locale, options) } -export const formatNumber = (value) => { +export const formatNumber = (value, locale) => { if (value === null || value === undefined) return '0' - return value.toLocaleString() + return value.toLocaleString(locale) } diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index 7709dd91b..d633e96f2 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -121,35 +121,35 @@ describe('formatDuration2', () => { describe('formatNumber', () => { it('handles null and undefined values', () => { - expect(formatNumber(null)).toEqual('0') - expect(formatNumber(undefined)).toEqual('0') + expect(formatNumber(null, 'en-CA')).toEqual('0') + expect(formatNumber(undefined, 'en-CA')).toEqual('0') }) it('formats integers', () => { - expect(formatNumber(0)).toEqual('0') - expect(formatNumber(1)).toEqual('1') - expect(formatNumber(123)).toEqual('123') - expect(formatNumber(1000)).toEqual('1,000') - expect(formatNumber(1234567)).toEqual('1,234,567') + expect(formatNumber(0, 'en-CA')).toEqual('0') + expect(formatNumber(1, 'en-CA')).toEqual('1') + expect(formatNumber(123, 'en-CA')).toEqual('123') + expect(formatNumber(1000, 'en-CA')).toEqual('1,000') + expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567') }) it('formats decimal numbers', () => { - expect(formatNumber(123.45)).toEqual('123.45') - expect(formatNumber(1234.567)).toEqual('1,234.567') + expect(formatNumber(123.45, 'en-CA')).toEqual('123.45') + expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567') }) it('formats negative numbers', () => { - expect(formatNumber(-123)).toEqual('-123') - expect(formatNumber(-1234)).toEqual('-1,234') - expect(formatNumber(-123.45)).toEqual('-123.45') + expect(formatNumber(-123, 'en-CA')).toEqual('-123') + expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234') + expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45') }) }) describe('formatFullDate', () => { it('format dates', () => { - expect(formatFullDate('2011', 'en-US')).toEqual('2011') - expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011') - expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985') + expect(formatFullDate('2011', 'en-CA')).toEqual('2011') + expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011') + expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985') expect(formatFullDate('199704')).toEqual('') }) }) From a128b3cf98a9c4e063526c8e3b7c76fd033a38f2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:41:09 +0000 Subject: [PATCH 61/64] fix(db): make playqueue position field an integer (#4481) --- .../20250823142158_make_playqueue_position_int.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 db/migrations/20250823142158_make_playqueue_position_int.sql diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql new file mode 100644 index 000000000..de20f0c79 --- /dev/null +++ b/db/migrations/20250823142158_make_playqueue_position_int.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE playqueue ADD COLUMN position_int integer; +UPDATE playqueue SET position_int = CAST(position as INTEGER) ; +ALTER TABLE playqueue DROP COLUMN position; +ALTER TABLE playqueue RENAME COLUMN position_int TO position; +-- +goose StatementEnd + +-- +goose Down From 1e8d28ff46239bba3e5ba38881d31a8d40f4af79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 6 Nov 2025 14:54:01 -0500 Subject: [PATCH 62/64] fix: qualify user id filter to avoid ambiguous column (#4511) --- persistence/user_repository.go | 1 + persistence/user_repository_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index a7181b1a7..7baa8f6a8 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.db = db r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ + "id": idFilter(r.tableName), "password": invalidFilter(ctx), "name": r.withTableName(startsWithFilter), }) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 7c0707ecd..8abbf76a9 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() { Expect(user.Libraries[0].ID).To(Equal(1)) }) }) + + Describe("filters", func() { + It("qualifies id filter with table name", func() { + r := repo.(*userRepository) + qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) + sel := r.selectUserWithLibraries(qo) + query, _, err := r.toSQL(sel) + Expect(err).NotTo(HaveOccurred()) + Expect(query).To(ContainSubstring("user.id = {:p0}")) + }) + }) }) From e918e049e2e75e8612750983e9494cb6f70c9215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 6 Nov 2025 15:07:09 -0500 Subject: [PATCH 63/64] 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> --- go.mod | 8 ++++++-- go.sum | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 932e4c211..bbe610710 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,12 @@ module github.com/navidrome/navidrome go 1.25.4 -// Fork to fix https://github.com/navidrome/navidrome/pull/3254 -replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d +replace ( + // Fork to fix https://github.com/navidrome/navidrome/issues/3254 + github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d + // Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396 + github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 +) require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 97fe24b35..059ddd19f 100644 --- a/go.sum +++ b/go.sum @@ -265,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 v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E= +github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/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= From 4f7dc105b0414cd202fc7e560d51b8abc27ad7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 6 Nov 2025 16:50:54 -0500 Subject: [PATCH 64/64] 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> --- persistence/album_repository_test.go | 2 ++ persistence/mediafile_repository_test.go | 2 +- persistence/persistence_suite_test.go | 13 +++++++++- persistence/playlist_repository_test.go | 33 ++++++++++++++++++++++++ persistence/playlist_track_repository.go | 2 +- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 4be89bcb8..a062b4398 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() { It("returns all records sorted", func() { Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ albumAbbeyRoad, + albumMultiDisc, albumRadioactivity, albumSgtPeppers, })) @@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() { Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ albumSgtPeppers, albumRadioactivity, + albumMultiDisc, albumAbbeyRoad, })) }) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 002b82499..ab926c00d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(6))) + Expect(mr.CountAll()).To(Equal(int64(10))) }) It("returns songs ordered by lyrics with a specific title/artist", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 1007d84fe..f3cb4f3d0 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -69,10 +69,12 @@ var ( albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2}) + albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4}) testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, albumRadioactivity, + albumMultiDisc, } ) @@ -94,13 +96,22 @@ var ( Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`, }) songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"}) - testSongs = model.MediaFiles{ + // Multi-disc album tracks (intentionally out of order to test sorting) + songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + testSongs = model.MediaFiles{ songDayInALife, songComeTogether, songRadioactivity, songAntenna, songAntennaWithLyrics, songAntenna2, + songDisc2Track11, + songDisc1Track01, + songDisc2Track01, + songDisc1Track02, } ) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 15ae438d9..7fad93b1e 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() { }) }) }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 01eec0d02..b3f9e0c07 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, order_album_artist_name", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "title": "order_title", // To make sure these fields will be whitelisted "duration": "duration",