mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b293b96256
|
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls. |
||
|
|
1a8463f7de
|
Merge commit from fork
* fix(share): always assign the authenticated user as share owner A share's UserID was taken from the request body and only defaulted when empty, so any authenticated user could create a share attributed to another user. For playlist shares the contents are resolved in the owner's library-access context, turning the spoofed owner into an access-escalation vector in multi-library setups. Force the owner from the request context at both the service boundary and the persistence layer, ignoring any client-supplied UserID. * fix(plugins): block SSRF to private IPs resolved from hostnames The HTTP host client only checked the literal host string, so a symbolic hostname (or a trailing-dot "localhost.") resolving to a private/loopback address bypassed the SSRF guard when a plugin declared no requiredHosts. Enforce the check at dial time via net.Dialer.Control on the resolved IP, which also covers redirect hops and DNS rebinding. When an explicit requiredHosts allowlist is set, defer to it as the operator's trust decision. * fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries Following review feedback: an allowlisted hostname authorizes the external service, not whatever private IP it may resolve or rebind to. Enforce the resolved-IP guard even when requiredHosts is set, permitting a private address only when a literal IP or CIDR entry explicitly covers it. This keeps "reach this external API" and "reach my internal network" as two separate, explicit operator decisions. * fix(plugins): treat unspecified addresses as private in the SSRF guard Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the private/loopback check. * fix(plugins): let a bare "*" allowlist reach private addresses Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a user-configured service on the LAN, whose address the manifest cannot know. Requiring a literal IP/CIDR entry broke them. Named hosts and subdomain wildcards still cannot resolve to private addresses. * refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool Build the client directly around the guarded transport instead of replacing a throwaway one, fail closed on an unparseable dial address, and close the per-plugin transport's idle connections when the plugin unloads. Trim stale comments. * fix(plugins): stop enabling extism's unguarded http_request host function Passing requiredHosts as the extism manifest's AllowedHosts enabled extism's own http_request (pdk.NewHTTPRequest), which only glob-matches the hostname and follows redirects without re-checking, bypassing the resolved-IP SSRF guard. Plugins must use host.HTTPSend. * fix(plugins): move bundled Rust examples to the host HTTP service Extism's built-in http_request is now disabled, so the webhook and Discord examples switch to nd_pdk::host::http::send. Update the README to say host.HTTPSend is the only supported way to make HTTP requests. * fix(plugins): move the Python example to the host HTTP service coverartarchive-py used extism's built-in Http.request, which is now disabled. Call Navidrome's http_send host function instead. The plugin can no longer run under the standalone extism CLI, so drop the CLI test targets and instructions. |
||
|
|
2dc0983629
|
fix: miscellaneous fixes for shares, artwork resize, auth limits, and watcher start (#6098)
* fix(artwork): cap declared image dimensions before resizing resizeStaticImage decoded the image with a raw image.Decode, so a small file declaring huge dimensions (e.g. a PNG header claiming 50k x 50k) forced a multi-gigabyte allocation on the serve-time resize path. The processor already guards its own decodes with decodeCapped; use it here too so the same 64M pixel cap applies to uploaded and sidecar images served through the cache. * fix(share): validate every resource ID and reject mixed types when saving Save only resolved the first ID in ResourceIDs to pick the resource type; the remaining IDs were never checked. A non-existent or hidden entity could ride along behind a valid first ID, and IDs of different kinds were accepted as one share. Resolve every ID as the current user and require all of them to be the same kind, returning ErrNotFound or ErrValidation otherwise. * fix(share): scope album and media file shares to the owner's libraries loadMedia already loaded artist and playlist shares as the share owner, but album and media_file shares used the repository context. Public share rendering carries no user, so the library filter was skipped and the share listed albums and tracks from libraries the owner cannot access. Streaming was already blocked, so only metadata leaked. Use ownerContext for all resource types. * fix(server): limit login payload size and surface first-admin creation errors The unauthenticated /login and /createAdmin handlers decoded the request body with no size limit. Add a body-limit middleware to the /auth route group that caps the payload at 8KiB, which is plenty for a username and password. Also make createAdminUser return the datastore error instead of logging it and returning nil, which previously let createAdmin proceed to a login attempt for a user that was never saved. * fix(conf): create the log file readable only by the owner The log file was created with mode 0644, so other local users could read it. Logs can contain usernames, paths and, at trace level, request details, so create it with 0600 instead. Existing files keep their current mode. * fix(lastfm): stop logging the auth token when fetching the session key fails The Last.fm callback token was written to the log as a structured field on failure. The redaction hook only matches value patterns, so it was not masked. Drop the field; the request ID is enough to correlate the failure. * fix(db): allow a music folder path containing a single quote on fresh databases The library table migration interpolated conf.Server.MusicFolder into the SQL with fmt.Sprintf, so a path such as /music/Rock 'n' Roll produced invalid SQL and the migration failed on a brand new database. Bind the path as a parameter instead. * fix(scanner): return an error when the folder watcher cannot start When notify.Watch failed, the watcher goroutine logged the error and exited, but never signalled the started channel, so Start blocked until its context was cancelled and left the watching flag set. Call notify.Watch before spawning the event loop, so Start returns the error right away, the started/failed signalling goes away, and the storage can be watched again later. * fix(jellyfin): limit the login request body size The Jellyfin AuthenticateByName endpoint decoded its JSON body with no size limit, the same gap the native /auth routes had. Export the login body-limit middleware from the server package and apply it to the Jellyfin login route, before the optional per-IP rate limiter, so both unauthenticated login surfaces share the same 8KiB cap. * fix(scanner): share one scanner instance across all injectors Each wire injector built its own scanner controller, so the Subsonic and native API routers held a different instance from the ones used by the startup scan, the periodic scan, the folder watcher and the SIGUSR1 handler. Status reads the in-progress file and folder counters from its own instance, so getScanStatus reported scanning=true with count=0 for every scan not started through the API. Verified live with a startup scan: master reports count 0 while scanning, this branch reports the real counts. Expose the controller through a singleton, as the watcher, broker and play tracker already are, and wire everything to it. New stays available for tests that need isolated controllers. * fix(share): do not panic when a media file share has no visible tracks Share.CoverArtID picked a random track for media file shares without checking that any track was loaded. The tracks are empty when the files went missing, were deleted, or the owner lost access to their library, and the public share page then panicked inside the random pick and returned a 500. Return an empty artwork ID instead, so the page renders with the placeholder cover. The old guard on the split resource IDs was dead code, since SplitN always returns at least one element. |
||
|
|
752b38609c
|
feat(artist): add Share and Download actions to the Artist detail page (#5944)
* feat(ui): add Share button to artist detail page * feat(ui): add Download button to artist detail page * fix(ui): scope artist share/download to album-artist content Gate the artist Share/Download actions on album-artist stats and show the album-artist size, since ZipArtist and the share query only cover album_artist_id songs. Previously the total (role-inclusive) size was shown and guest-only artists could produce an empty archive. Applies to the artist toolbar, the shared context menu, and the download dialog title. * fix: match artist download/share to album-artist participation ZipArtist and the artist share query filtered the deprecated album_artist_id column, which only stores the first album artist of a track. Secondary album-artists (co-credited but not first) got an empty download/share even though the UI offered it. Filter by the album-artist role participation instead, matching the artist's album-artist stats used to gate the actions. Also cover the artist-specific size branch of the download dialog. * fix(share): scope artist shares to the owner's libraries The artist share query broadened to album-artist participation, which could pull a secondary album artist's tracks from libraries the (non-admin) share owner cannot access into the public share. Load the artist share as the owner so their library access is applied, mirroring how playlist shares already work. Adds a repository test covering co-album-artist inclusion and library scoping. * test(share): assert album participation branch of artist shares Link the co-album-artist fixtures to albums and assert share.Albums (used by Subsonic getShares) includes the accessible album and excludes the one in a library the owner cannot access, so the album participation + scoping branch is covered too. * fix: exclude missing files from artist download/share actions An artist's stats still count files that went missing, so the toolbar/context menu could offer Download/Share for an artist whose files are all gone, while the share query (missing=false) returns nothing and downloads open dead paths. Hide the actions when the artist is missing and exclude missing files from ZipArtist, matching the share semantics. * refactor: dedupe artist download-size and share-owner lookups Extract the 'album-artist download size (or none when missing)' rule into a single artistDownloadSize() helper shared by the toolbar, context menu, and download dialog, and factor the duplicated share-owner context lookup into a shareRepository.ownerContext() method used by both the artist and playlist share cases. * refactor(ui): move artistDownloadSize helper to common utils is for domain-agnostic, potentially portable code; this helper is Navidrome-specific (artist stats shape), so it belongs in common. Consumers import it directly from common/artist to avoid pulling in the common barrel. |
||
|
|
fa138afea5
|
fix(playlist/share): apply user library access to import and sharing paths (#5640)
* fix(playlist): respect the user's library access when resolving M3U paths FindByPaths looked up paths across all libraries, so importing an M3U could add tracks from libraries the importing user has no access to. Apply the user's library filter to the lookup, matching every other media_file read. Admins and the (admin-context) scanner are unaffected. * fix(share): scope shared playlist tracks to the owner's libraries loadMedia loaded playlist tracks with a fake-admin context, so a shared playlist could include tracks from libraries the owner has no access to. Load as the share owner instead, so the library filter applies. Admin-owned shares are unchanged. * fix(share): only serve shared tracks the owner can access A shared stream fetched the media file by id without checking the share owner's library access, so it could serve tracks from libraries the owner has no access to. Gate share-scoped streams on the owner's library access. Non-share streams are unaffected. * test(share): tidy library-access test setup Consolidate the repeated share-owner test fixture in handleStream into a helper, assert on track fields with HaveField instead of building an id slice, and delete the scratch media file through the public repository method. * style: trim verbose comments in library-access checks * fix(share): guard against nil owner and clean up test users Add a nil check after loading the share owner so a missing user yields a clear error instead of a possible nil dereference, and delete the users created by the new tests in their AfterEach blocks. * fix(share): avoid panic when a shared playlist is no longer visible to its owner Tracks() returns nil when the playlist can't be loaded under the owner's context (e.g. a public playlist shared by a non-owner that was later made private). Capture the result and return early instead of chaining GetAll on a nil repository, leaving the share with no tracks. |
||
|
|
1e7996f5d7 |
fix(share): enforce per-user ownership on share reads
Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test. |
||
|
|
37908d3cea
|
fix: enforce ownership atomically on player and share updates (#5563)
* fix(player): enforce ownership atomically on player update
The native API PUT /api/player/{id} authorized writes using the userId in
the request body via isPermitted, while the actual write targeted the row
by the URL id. A non-admin user could set userId to their own id in the
body to pass the check, then overwrite and reassign ownership of another
user's player row identified by the URL id (cross-tenant takeover).
Add updateOwned on the base repository: an atomic, ownership-restricted
UPDATE that folds the owner predicate (user_id = caller) into the WHERE
clause for non-admins, so a row owned by another user simply does not
match and no write happens. It also never writes user_id, so ownership is
immutable on update and no caller (admin included) can reassign a player
to a different owner. Unlike put, it never falls through to an INSERT, so
a non-matching id returns ErrNotFound instead of creating a row.
playerRepository.Update now uses updateOwned. Extract filterUpdateValues,
shared by put and updateOwned, so the update-column filtering lives in one
place. The create path (Save) keeps the body-based isPermitted check,
which is correct for new records.
Add regression tests covering the spoofed-userId hijack, regular-user and
admin ownership reassignment, legitimate owner updates, and the
nonexistent-player case.
* fix(share): enforce ownership atomically on share update
shareRepository.Update authorized writes with a separate checkOwnership
SELECT, then wrote the row via put(). The check and the write were two
statements (a TOCTOU window), put() could fall through to an INSERT on a
missing id, and put() would write user_id if present in the update
columns, so ownership was mutable on update.
Switch Update to updateOwned, which folds the owner predicate into the
UPDATE's WHERE clause, never writes user_id, and never inserts. This
makes the write atomic and ownership immutable, and drops the extra
ownership SELECT on the happy path.
To preserve the previous 403/404 distinction, updateOwned now classifies
a non-matching id: it runs a follow-up existence check only on the
failure path (count == 0, where no write happened, so no TOCTOU) and
returns ErrPermissionDenied when the row exists but is owned by another
user, ErrNotFound when the id is missing. The player path inherits this:
its tests now expect ErrPermissionDenied for a non-owner targeting an
existing row, and ErrNotFound only for a genuinely missing id.
Add share regression tests for the nonexistent-id and ownership-
reassignment cases. checkOwnership remains in use by Delete.
* refactor(persistence): extract canonical ownerFilter predicate
The non-admin owner-restriction predicate (user_id = me, exempting admins
and headless contexts) was spelled out independently in updateOwned and in
playerRepository.addRestriction. The two copies had drifted: addRestriction
did not exempt the headless/invalid user, so a headless context restricted
to user_id = "-1" (matching nothing) while updateOwned exempted it.
Extract sqlRepository.ownerFilter as the single definition and route both
call sites through it. addRestriction now exempts the headless user too;
that path is only reachable from the authenticated native API, so there is
no production behavior change, but the latent divergence is removed.
playlistRepository.userFilter is intentionally left alone: it encodes a
different policy (public OR owner_id = me, on the owner_id column).
* fix(share): preserve all-columns update path in Update
shareRepository.Update unconditionally appended "updated_at" to cols.
filterUpdateValues treats an empty cols as "update every column", so when
a caller passes no columns, appending "updated_at" turned an all-columns
update into an updated_at-only one, silently dropping every other field.
The REST controller always populates cols from the request-body field
names, so this path is not reachable through the native API and the
behavior was latent (and pre-existing). Guard the append so the
all-columns path is preserved, and add a regression test that updates
with no columns and asserts the other fields persist.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
|
||
|
|
6b8fcc37c6
|
fix(share): add ownership checks to Delete and Update (#5189)
* test(share): add failing tests for Delete ownership checks * fix(share): add ownership check to Delete * test(share): add failing tests for Update ownership checks * fix(share): add ownership check to Update * refactor(share): extract checkOwnership helper with lightweight query - Deduplicate ownership check from Delete and Update into a single helper - Use a minimal single-column SELECT instead of Get (avoids loadMedia overhead) - Use positive bypass form (IsAdmin || invalidUserId) matching codebase convention * fix(share): convert model.ErrNotFound to rest.ErrNotFound in checkOwnership Ensure consistent 404 responses when a nonexistent share ID is passed to Delete or Update, by handling the conversion in checkOwnership rather than relying on the subsequent write operation. |
||
|
|
a704e86ac1
|
refactor: run Go modernize (#5002) | ||
|
|
c193bb2a09
|
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>
|