Compare commits

...

9 Commits

Author SHA1 Message Date
Xabi
8b3a1800de
Merge d2a254077d54eb6849be37a468145e8d0394bef6 into 32e1313fc6ddf7100af094d14df13d47735a44bf 2025-11-16 15:12:34 -06:00
Kendall Garner
32e1313fc6
ci: bump plugin compilation timeout for regressions (#4690) 2025-11-16 13:46:32 -05:00
Deluan
489d5c7760 test: update make test-race target to use PKG variable for improved flexibility
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-16 13:41:22 -05:00
Kendall Garner
0f1ede2581
fix(scanner): specify exact table to use for missing mediafile filter (#4689)
In `getAffectedAlbumIDs`, when one or more IDs is added, it adds a filter `"id": ids`.
This filter is ambiguous though, because the `getAll` query joins with library table, which _also_ has an `id` field.
Clarify this by adding the table name to the filter.

Note that this was not caught in testing, as it only uses mock db.
2025-11-16 12:54:28 -05:00
Deluan Quintão
395a36e10f
fix(ui): fix library selection state for single-library users (#4686)
* fix: validate library selection state for single-library users

Fixes issues where users with a single library see no content when
selectedLibraries in localStorage contains library IDs they no longer
have access to (e.g., after removing libraries or switching accounts).

Changes:
- libraryReducer: Validate selectedLibraries when SET_USER_LIBRARIES
  is dispatched, filtering out invalid IDs and resetting to empty for
  single-library users (empty means 'all accessible libraries')
- wrapperDataProvider: Add defensive validation in getSelectedLibraries
  to check against current user libraries before applying filters
- Add comprehensive test coverage for reducer validation logic

Fixes #4553, #4508, #4569

* style: format code with prettier
2025-11-15 17:42:28 -05:00
Deluan
0161a0958c fix(ui): add CreateButton back to LibraryListActions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-15 17:31:37 -05:00
Xabi
d2a254077d
Update eu.json, typo
Fixes a typo.
2025-11-10 12:03:49 +01:00
Xabi
1f3258ba08
Update eu.json, now with missing comma
There was a comma missing.
2025-11-10 11:28:52 +01:00
Xabi
d4152293ba
Update eu.json
Added Library strings
2025-11-10 11:22:15 +01:00
8 changed files with 309 additions and 15 deletions

View File

@ -54,7 +54,7 @@ testall: test-race test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags netgo -race -shuffle=on ./...
go test -tags netgo -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests

View File

@ -166,7 +166,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
if len(ids) > 0 {
filters = squirrel.And{
squirrel.Eq{"missing": true},
squirrel.Eq{"id": ids},
squirrel.Eq{"media_file.id": ids},
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
@ -22,8 +23,11 @@ var _ = Describe("Plugin Manager", func() {
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
originalTimeout := conf.Server.DevPluginCompilationTimeout
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir

View File

@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
"lastAccessAt": "Azken sarbidea"
"lastAccessAt": "Azken sarbidea",
"libraries": "Liburutegiak"
},
"helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"validation": {
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
},
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko"
"clickHereForToken": "Egin klik hemen tokena lortzeko",
"selectAllLibraries": "Hautatu liburutegi guztiak",
"adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
}
},
"player": {
@ -254,6 +263,7 @@
"fields": {
"path": "Bidea",
"size": "Tamaina",
"libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
@ -263,6 +273,58 @@
"notifications": {
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
}
},
"library": {
"name": "Liburutegia |||| Liburutegiak",
"fields": {
"name": "Izena",
"path": "Fitxategiaren bidea",
"remotePath": "Urruneko bidea",
"lastScanAt": "Azken araketa",
"songCount": "Abestiak",
"albumCount": "Albumak",
"artistCount": "Artistak",
"totalSongs": "Abestiak",
"totalAlbums": "Albumak",
"totalArtists": "Artistak",
"totalFolders": "Karpetak",
"totalFiles": "Fitxategiak",
"totalMissingFiles": "Fitxategiak faltan",
"totalSize": "Tamaina guztira",
"totalDuration": "Iraupena",
"defaultNewUsers": "Defektuz erabiltzaile berrientzat",
"createdAt": "Sortze-data",
"updatedAt": "Eguneratze-data"
},
"sections": {
"basic": "Oinarrizko informazioa",
"statistics": "Estatistikak"
},
"actions": {
"scan": "Arakatu liburutegia",
"manageUsers": "Kudeatu erabiltzaileen sarbidea",
"viewDetails": "Ikusi xehetasunak"
},
"notifications": {
"created": "Liburutegia ondo sortu da",
"updated": "Liburutegia ondo eguneratu da",
"deleted": "Liburutegia ondo ezabatu da",
"scanStarted": "Liburutegiaren araketa hasi da",
"scanCompleted": "Liburutegiaren araketa amaitu da"
},
"validation": {
"nameRequired": "Liburutegiaren izena beharrezkoa da",
"pathRequired": "Liburutegiaren bidea beharrezkoa da",
"pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
"pathNotFound": "Ez da liburutegiaren bidea aurkitu",
"pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
"pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
},
"messages": {
"deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
}
},
"ra": {
@ -450,6 +512,12 @@
},
"menu": {
"library": "Liburutegia",
"librarySelector": {
"allLibraries": "Liburutegi guztiak (%{count})",
"multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
"selectLibraries": "Hautatu liburutegiak",
"none": "Bat ere ez"
},
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",

View File

@ -12,7 +12,21 @@ const isAdmin = () => {
const getSelectedLibraries = () => {
try {
const state = JSON.parse(localStorage.getItem('state'))
return state?.library?.selectedLibraries || []
const selectedLibraries = state?.library?.selectedLibraries || []
const userLibraries = state?.library?.userLibraries || []
// Validate selected libraries against current user libraries
const userLibraryIds = userLibraries.map((lib) => lib.id)
const validatedSelection = selectedLibraries.filter((id) =>
userLibraryIds.includes(id),
)
// If user has only one library, return empty array (no filter needed)
if (userLibraryIds.length === 1) {
return []
}
return validatedSelection
} catch (err) {
return []
}

View File

@ -1,5 +1,5 @@
import React, { cloneElement } from 'react'
import { sanitizeListRestProps, TopToolbar } from 'react-admin'
import { sanitizeListRestProps, TopToolbar, CreateButton } from 'react-admin'
import LibraryScanButton from './LibraryScanButton'
const LibraryListActions = ({
@ -23,6 +23,7 @@ const LibraryListActions = ({
})}
<LibraryScanButton fullScan={false} />
<LibraryScanButton fullScan={true} />
<CreateButton />
</TopToolbar>
)
}

View File

@ -8,18 +8,39 @@ const initialState = {
export const libraryReducer = (previousState = initialState, payload) => {
const { type, data } = payload
switch (type) {
case SET_USER_LIBRARIES:
case SET_USER_LIBRARIES: {
const newUserLibraryIds = data.map((lib) => lib.id)
// Validate and filter selected libraries to only include IDs that exist in new user libraries
const validatedSelection = previousState.selectedLibraries.filter((id) =>
newUserLibraryIds.includes(id),
)
// Determine the final selection:
// 1. If first time setting libraries (no previous user libraries), select all
// 2. If user now has only one library, reset to empty (no filter needed)
// 3. Otherwise, use validated selection (may be empty if all previous selections were invalid)
let finalSelection
if (
previousState.selectedLibraries.length === 0 &&
previousState.userLibraries.length === 0
) {
// First time: select all libraries
finalSelection = newUserLibraryIds
} else if (newUserLibraryIds.length === 1) {
// Single library: reset selection (empty means "all accessible")
finalSelection = []
} else {
// Multiple libraries: use validated selection
finalSelection = validatedSelection
}
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,
selectedLibraries: finalSelection,
}
}
case SET_SELECTED_LIBRARIES:
return {
...previousState,

View File

@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest'
import { libraryReducer } from './libraryReducer'
import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
describe('libraryReducer', () => {
const mockLibraries = [
{ id: '1', name: 'Music Library' },
{ id: '2', name: 'Podcasts' },
{ id: '3', name: 'Audiobooks' },
]
const initialState = {
userLibraries: [],
selectedLibraries: [],
}
describe('SET_USER_LIBRARIES', () => {
it('should set user libraries and select all on first load', () => {
const action = {
type: SET_USER_LIBRARIES,
data: mockLibraries,
}
const result = libraryReducer(initialState, action)
expect(result.userLibraries).toEqual(mockLibraries)
expect(result.selectedLibraries).toEqual(['1', '2', '3'])
})
it('should reset selection to empty when user has only one library', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1', '2'],
}
const action = {
type: SET_USER_LIBRARIES,
data: [mockLibraries[0]], // Only one library now
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual([mockLibraries[0]])
expect(result.selectedLibraries).toEqual([]) // Reset for single library
})
it('should filter out invalid library IDs from selection', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1', '2', '3'],
}
const action = {
type: SET_USER_LIBRARIES,
data: [mockLibraries[0], mockLibraries[1]], // Only libraries 1 and 2 remain
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual([mockLibraries[0], mockLibraries[1]])
expect(result.selectedLibraries).toEqual(['1', '2']) // Library 3 removed
})
it('should keep valid selection when libraries change', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1'],
}
const action = {
type: SET_USER_LIBRARIES,
data: mockLibraries, // Same libraries
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual(mockLibraries)
expect(result.selectedLibraries).toEqual(['1']) // Selection preserved
})
it('should handle selection becoming empty after filtering invalid IDs', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1', '2'],
}
const newLibraries = [{ id: '4', name: 'New Library' }]
const action = {
type: SET_USER_LIBRARIES,
data: newLibraries,
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual(newLibraries)
expect(result.selectedLibraries).toEqual([]) // All selected IDs were invalid
})
it('should handle transition from multiple to single library with invalid selection', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['2', '3'], // User had libraries 2 and 3 selected
}
const action = {
type: SET_USER_LIBRARIES,
data: [mockLibraries[0]], // Now only has access to library 1
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual([mockLibraries[0]])
expect(result.selectedLibraries).toEqual([]) // Reset for single library
})
it('should handle empty library list', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1', '2'],
}
const action = {
type: SET_USER_LIBRARIES,
data: [],
}
const result = libraryReducer(previousState, action)
expect(result.userLibraries).toEqual([])
expect(result.selectedLibraries).toEqual([]) // All selections filtered out
})
})
describe('SET_SELECTED_LIBRARIES', () => {
it('should update selected libraries', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1'],
}
const action = {
type: SET_SELECTED_LIBRARIES,
data: ['2', '3'],
}
const result = libraryReducer(previousState, action)
expect(result.selectedLibraries).toEqual(['2', '3'])
expect(result.userLibraries).toEqual(mockLibraries) // Unchanged
})
it('should allow setting empty selection', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1', '2'],
}
const action = {
type: SET_SELECTED_LIBRARIES,
data: [],
}
const result = libraryReducer(previousState, action)
expect(result.selectedLibraries).toEqual([])
})
})
describe('unknown action', () => {
it('should return previous state for unknown action', () => {
const previousState = {
userLibraries: mockLibraries,
selectedLibraries: ['1'],
}
const action = {
type: 'UNKNOWN_ACTION',
data: null,
}
const result = libraryReducer(previousState, action)
expect(result).toBe(previousState) // Same reference
})
})
})