21 Commits

Author SHA1 Message Date
Deluan
9e3fc91cec test(artwork): separate the cover swap in time for Windows clock granularity
The quick-scan e2e swapped the cover milliseconds after the first serve; Windows'
~15ms timer granularity could collapse the stored version and the new folder
timestamp into equal values, failing the staleness assertion. A 50ms gap makes the
ordering deterministic on all platforms.
2026-07-17 23:36:52 -04:00
Deluan
f0fe070ba1 fix(scanner): cap folder images_updated_at at scan time
A future-stamped image file (clock skew on NAS mounts) previously flowed verbatim
into folder.images_updated_at and from there into the album artwork version, while
the stored blur_hash_updated_at is capped at now on write. The DTO staleness gate
then kept emitting the fake blurhash until wall time caught up with the file mtime.
Capping at the source keeps future values out of the DB entirely; rotation is
preserved because any later change is capped to a later scan time. Capping in the
DTO instead would be worse: the version would become a moving target and the fake
seed would rotate on every request.
2026-07-17 22:47:43 -04:00
Deluan
07af29fbb4 fix(jellyfin): fold folder image mtimes into the album blurhash version
An in-place cover-file swap followed by a quick scan updates only the folder's
images_updated_at: no tracks are imported, so the album row never moves and
ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting
the previous stored blurhash, and clients that key their cover caches on it never
refetched the image, so the served-bytes recompute could never run.

The album select now surfaces the newest folder images_updated_at (bare-column
correlated subquery over json_each(folder_ids), so the datetime decltype survives
and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K
track production copy: ~90us/page added, no query-plan change; the subquery is a
PK point lookup per folder with at most two rows to order. Artist images and
playlist sidecars have the same theoretical gap but their timestamps cannot be
derived in SQL; they remain a documented follow-up.
2026-07-17 22:14:13 -04:00
Deluan
2499de2bac perf(artwork): key blurhash dedup by identity, plus review cleanups
The seen map was keyed by the full ArtworkID, which embeds the client token's
LastUpdate: every scan bump rotated the key, so the same-bytes dedup (and its
cheap version re-persist path) never fired in production and stale entries
accumulated forever. The key now zeroes LastUpdate, making the map bounded by
entity count and restoring the tested dedup behavior.

Cleanups from the same review: the base83 encoder is now exported from the
blurhash package and the DTO's duplicate copy is deleted; the always-set
blurHashes field lost its test-shaped nil guards; clearIfStored dropped its
inert version parameter (cleared rows never have their timestamp read); the
teeReader done flag is replaced by nilling the callback; worker-era comments
(async, cache-miss recompute) were updated to the inline design; and the e2e
specs assert directly instead of polling, since the hash is persisted before
the read helpers return.
2026-07-17 21:46:12 -04:00
Deluan
83e9bb8180 refactor(artwork): compute the blurhash inline, drop the background worker
Decode+encode is a few ms (the encoder downscales before its pixel loops), and the
tee fires on Close after the response is fully written, so the hash can be computed
in the serving goroutine: the queue, wake/stop lifecycle, lazy-start admin context,
and the e2e worker-teardown ordering all become unnecessary and are removed. This
also closes two correctness holes the queue had: a deletion signal could be dropped
behind a pending serve job, and buffered image bytes had no global cap.

The in-memory dedup now remembers a checksum of the served bytes plus the persisted
version: identical serves skip the decode and the write, but the same bytes under a
newer entity version re-persist, so blur_hash_updated_at keeps pace with row updates
and the Jellyfin DTO's staleness gate keeps emitting the stored hash after scans.
Placeholder-triggered clears check the seen map, then the stored row, so coverless
entities cost one row read once per process instead of a probe and write per serve.
UpdateBlurHash is a plain UPDATE with no user filtering, so the request context is
used directly (a client abort is survived via context.WithoutCancel).
2026-07-17 21:20:46 -04:00
Deluan
b63c9b095b test(artwork): cover playlist placeholder-clear via the tee
A playlist that loses its sidecar cover serves the bundled placeholder through
Get; those bytes flow through the tee, the runner recognizes the placeholder, and
clears the stored hash. This exercises the free deletion path (no GetOrPlaceholder
needed, since the playlist reader chain ends in fromAlbumPlaceholder).
2026-07-17 20:36:38 -04:00
Deluan
e8fac4c335 feat(artwork): trigger blurhash from the served-bytes tee
Get wraps an eligible original-size serve (album/artist/playlist) in a teeCachedStream
so the bytes streamed to the client are also captured; on a fully-consumed Close the
runner hashes exactly what was served. GetOrPlaceholder routes a vanished album/artist
cover through EnqueueClearIfGone, since no bytes flow through the tee on ErrUnavailable.
capAtNow keeps a future mtime out of the stored version. The mtime-preserved cover-swap
characterization test (previously pending) now passes, and the disappearing-cover e2e
serves through GetOrPlaceholder to match the real Jellyfin/Subsonic path.
2026-07-17 20:34:47 -04:00
Deluan
eb5ecabc5a test(artwork): characterize mtime-preserved cover swap staleness (pending)
A cover replaced in place without a mtime change is not re-hashed today: the
freshness guard skips recompute when no timestamp moved, so the stored blurhash
(and the client's cover cache keyed by it) stays stale. Marked pending until the
served-bytes tee lands, which recomputes from the exact bytes served.
2026-07-17 19:35:13 -04:00
Deluan
bf7ae5e82e fix(artwork): cap the blurhash snapshot at now to survive future mtimes
A future-dated artwork file mtime (clock skew, or a file stamped ahead of the
server clock) flowed into the reader's LastUpdated snapshot and was persisted
verbatim as blur_hash_updated_at. Both the worker's freshness guard and the
Jellyfin DTO use a !Before comparison against that timestamp, so a later
legitimate cover change whose row/mtime clock was still behind the future value
would be skipped, pinning the stored hash (and the client's cached cover) until
wall time caught up.

Cap the snapshot at time.Now() in process() before every freshness comparison
and persist, so a real later change always advances past it. Normal past mtimes
(the legitimate 'snapshot exceeds row version' case) are unaffected.
2026-07-17 15:51:01 -04:00
Deluan
66d6fdc1a6 test(artwork): stop the blurhash worker before spec TempDir cleanup
The artwork e2e suite stopped the worker via a DeferCleanup registered in
setupHarness's BeforeEach, which Ginkgo runs LAST — after a spec body's own
GinkgoT().TempDir() cleanups. With the cache disabled in these tests, every
artwork serve now enqueues a blurhash recompute, so the worker can still be
reading a spec-local sidecar file when its TempDir is removed. On Windows that
unlink fails ("the process cannot access the file because it is being used by
another process"), which flaked the playlist case-insensitive sidecar spec.

Move the worker shutdown to a suite-level AfterEach, which runs before any
spec-body DeferCleanup, so no artwork file is held open when TempDir removal
runs. Close() is idempotent, so the change is safe across specs.
2026-07-17 15:03:19 -04:00
Deluan
3759488db5 refactor(artwork): trigger blurhash recompute from the cache fill
The blurhash worker previously recomputed on every artwork serve and
reconciled a row-level freshness oracle against serve-time hints (force,
sourceGone, imageUpdatedAt) to decide whether the served bytes had actually
changed. Every staleness bug found in review was one case where that weak
oracle disagreed with the true one, and each fix imported one more serve-time
fragment into it.

Move the trigger to the image-cache fill instead: an original-size cache miss
is exactly when the served bytes change, so the reader's LastUpdated snapshot
is the truth and no reconciliation is needed. Resized fills recurse through
Get(size=0) and hash the original once; a disabled cache reports every original
serve as a fill, keeping real hashes available (the worker's unchanged-hash
guard keeps that write-free).

This deletes the force/sourceGone/imageUpdatedAt flags, the double-freshness
comparison, and the entire negative cache. Only the two irreducible pieces
survive: the placeholder byte-compare and the ErrUnavailable clear-hook
(EnqueueGone), since deletion-without-rescan is invisible to every passive
signal. Adds an e2e test for in-place cover swaps, the scenario that drove the
deleted machinery, now covered structurally by the fill trigger.

Schema, DTO, repositories and the blurhash encoder package are unchanged.
2026-07-17 14:47:01 -04:00
Deluan
7a61776c1c fix(artwork): clear a fresh-looking hash when the serve finds no source
In the window before a rescan records a deleted cover (or after cache
eviction/restart), no row or mtime signal moves, so the freshness skip kept
the stale hash. ErrUnavailable serves now carry a sourceGone signal that
bypasses the skip only while a stored hash remains to clear — afterwards
the negative cache applies, so artwork-less entities still don't re-resolve
per serve. The e2e spec now covers the no-rescan window.
2026-07-17 13:53:08 -04:00
Deluan
a7eec3afc3 fix(artwork): clear the stored hash when the artwork source disappears
A removed cover made Get error with ErrUnavailable before the enqueue, so
the worker never saw the entity and the stale hash kept being emitted. The
unavailable path now enqueues too; the worker's reader signal carries the
folder change and the existing no-result clearing applies. Covered by an
e2e spec exercising the full disappear-and-clear flow.
2026-07-17 13:33:45 -04:00
Deluan
43500c0ffe fix(artwork): persist the image freshness signal to stop per-serve recomputes
When a cover file's mtime is newer than the entity row (common), persisting
the row version made the freshness check fail on every serve, re-encoding
and re-writing the same hash each time. The snapshot now stores the newest
signal (row version or image mtime), and both freshness checks accept a
snapshot at-or-after the row version.
2026-07-17 11:59:24 -04:00
Deluan
b040345fb0 fix(artwork): stop the blurhash worker on Close to fix test data races
CI's race detector caught the process-lifetime worker goroutine outliving
Ginkgo specs and touching mocks/fake filesystems being torn down. The
worker now has a stop() that waits for in-flight work, exposed as Close()
on the artwork service; unit suites close it up front (they don't exercise
blurhash), the artwork e2e suite closes it on cleanup.
2026-07-17 11:51:24 -04:00
Deluan
76e1fba067 test(artwork): e2e coverage for async blurhash persistence 2026-07-16 21:52:28 -04:00
Deluan Quintão
af78bdeb3a
fix(artwork): never serve artist folder images as album art (#5596)
* test(artwork): add failing e2e tests for artist image leaking as album art

Reproduces a v0.62.0 regression (#5451/#5457): the album cover-art
parent-folder fallback can include the artist folder, serving the artist
thumbnail (e.g. Artist/folder.jpg) as album art for any album without
image files in its own folder(s). Covers three scenarios: a plain
Artist/Album layout with no album images, a single-disc album spread
across sibling folders under the artist folder, and a spread album whose
own front.jpg is shadowed by the artist's cover.jpg via CoverArtPriority
order. Also adds an albumByName test helper for multi-album layouts.

The tests are expected to fail until the parent-folder inclusion is
gated by a structural check (skip the common parent when audio from
other albums lives under it).

* fix(artwork): never serve artist folder images as album art

The album cover-art parent-folder fallback (introduced in #5451/#5457)
could include the artist folder as a source of album images, serving the
artist thumbnail (e.g. Artist/folder.jpg) as cover art for any album
without image files in its own folder(s). This affected both plain
Artist/Album layouts and single-disc albums spread across sibling
folders under the artist folder.

Gate the common-parent inclusion with a structural check: the parent
only qualifies as an album root when no audio belonging to other albums
lives in it or anywhere beneath it. An artist folder contains other
albums' tracks, while an album root above disc subfolders contains only
this album's, so the check works for any disc folder naming scheme and
never affects the multi-disc fixes from #5376/#5456. A single-album
artist with no images anywhere remains structurally indistinguishable
from an album root and is a known residual case.

* refactor(artwork): move album-root audio check into folder repository

Replace the raw subtree SQL (LIKE/ESCAPE expression and wildcard
escaping) that lived in core/artwork with an explicit
FolderRepository.HasAudioOutsideFolders method, implemented in the
persistence layer next to the existing folder-subtree query pattern.
This also removes the test mock's brittle dispatch that sniffed the
generated SQL to recognize the query; the fake now overrides the new
method directly.

Extract the whole parent-folder resolution from loadAlbumFoldersPaths
into an albumRootParent helper, flattening four levels of nesting back
into a linear flow. Behavior is unchanged; the unit test for a parent
containing audio moved to the persistence suite, with added coverage
for subtree boundaries, missing folders, and LIKE-wildcard escaping in
folder paths.

* refactor(persistence): use exists helper in HasAudioOutsideFolders

Replace the hand-rolled count(*) query with the repository's canonical
exists helper, as suggested in PR review.
2026-06-13 13:29:29 -04:00
Deluan Quintão
8f0b4930ff
refactor(conf): replace eager dir creation with lazy Dir type (#5495)
* feat(conf): add Dir type with lazy directory creation

Introduces the Dir type that wraps a directory path string and defers
os.MkdirAll until the first call to Path() or MustPath(), using sync.Once
to ensure the creation happens exactly once. Implements fmt.Stringer,
encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration.
Includes Ginkgo/Gomega tests covering all methods and error paths.

* refactor(conf): replace eager dir creation with lazy Dir type

Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from
string to Dir. Remove all os.MkdirAll calls from Load() so directories
are created lazily on first Path()/MustPath() call. Artwork folder
creation was already handled at point-of-use in image_upload.go.

Add SnapshotConfig() to conf package for safe test config save/restore
that avoids copying sync.Once inside Dir fields. Fix copy-lock vet
warning in nativeapi/config.go by marshalling pointer instead of value.

* refactor(conf): migrate tests and db init to lazy Dir type

Update all test files to use conf.NewDir() for Dir field assignments.
Ensure DataFolder is created lazily when the database is first opened
in db.Db(). Remove eager directory creation from conf.Load() tests.

* fix(conf): address review findings for Dir type

- Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match
  original behavior). Add NewDirWithPerm for PluginsFolder (0700).
- Use Path() instead of MustPath() in db.Prune() to avoid logFatal
  from background cron job.
- Panic on marshal/unmarshal errors in SnapshotConfig (test helper).
- Clean up redundant String()/MustPath() calls in plugin manager.
- Remove dead code in dir_test.go.

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

* fix(conf): add GoString to Dir for clean config dump output

Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path
string instead of internal struct fields (sync.Once, perm, err).
Also add TODO comment to configtest about removing the indirection.

* fix(dir): improve error logging in MustPath method

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

* refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder

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

* fix(conf): address PR review feedback

- Ensure Plugins.Folder always uses 0700, even when user-configured
  (previously only the derived default got restrictive permissions).
- Create LogFile parent directory before opening, so LogFile paths
  inside a not-yet-created DataFolder work correctly.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-13 17:44:22 -03:00
Deluan Quintão
f48416685f
fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457)
* fix(artwork): include top-level album folders in parent cover art lookup

The Path != "." guard added in #5451 was too aggressive — it excluded
any folder with Path=".", which includes top-level album folders (not
just the library root). Changed to ParentID != "" which correctly
excludes only the actual library root folder.

Fixes #5456

* fix: correct comment in test — album is under library root, not artist root

* test: add ascii tree diagram to top-level album e2e test

* test: replace internal bug references with issue link in e2e comments

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

* test: add e2e test matching reporter's exact library layout (#5456)

Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs
using the reporter's actual folder names to verify artwork resolution
works for non-top-level album folders too.

* fix(scanner): use a syntectic admin user when no admin user is found

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

* fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache

When Phase 3 corrects an album's FolderIDs (or any other field), bump
UpdatedAt to the current time. This ensures the artwork cache key changes,
invalidating any stale artwork that was resolved and cached during Phase 1
when the album had incomplete folder data.

* fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache

Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt
semantics) and instead includes album.ImportedAt in the artwork cache key
computation. Since ImportedAt is bumped to time.Now() on every album Put,
any Phase 3 correction naturally invalidates cached artwork that was
resolved mid-scan with incomplete folder data.

* fix(artwork): simplify lastUpdate logic using TimeNewest utility

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 17:26:39 -04:00
Deluan Quintão
a00152397e
fix(artwork): prefer album-root images over disc-subfolder images for multi-disc albums (#5451)
Fixed two bugs in album cover art resolution for multi-disc layouts:

1. compareImageFiles now sorts by path depth (shallower first) when basenames
   tie, so album-root images like Artist/Album/cover.jpg are preferred over
   disc-subfolder images like Artist/Album/CD1/cover.jpg.

2. commonParentFolder now includes the parent folder for single-disc-subfolder
   albums, with a Path != "." guard to avoid pulling artist-folder images.

Closes #5376
2026-05-02 19:48:44 -04:00
Deluan Quintão
5d1c1157b5
refactor(artwork): migrate readers to storage.MusicFS and add e2e suite (#5379)
* test(artwork): add e2e suite documenting album/disc resolution

Adds core/artwork/e2e/ with a real-tempdir + scanner harness that exercises
artwork resolution end-to-end. Covers album and disc kinds; pending (PIt)
cases document two known bugs in reader_album.go for regression-guard
flipping once they are fixed.

* refactor(artwork): add libraryFS helper to resolve MusicFS for a library

* test(artwork): tighten libraryFS test isolation and add scheme-error case

* test(artwork): update libraryFS test description to match implementation

* refactor(artwork): convert fromExternalFile to use fs.FS

Add a temporary fromExternalFileAbs shim so existing absolute-path callers
still compile; the shim is removed once all readers are migrated.

* refactor(artwork): make fromExternalFileAbs a thin delegator

Introduce a minimal osDirectFS adapter so the shim no longer duplicates
the matching loop. Both will be removed in Task 9.

* refactor(artwork): convert fromTag to taglib.OpenStream over fs.FS

Add a temporary fromTagAbs shim so existing absolute-path callers still
compile; removed in Task 9. Reuses the osDirectFS adapter from Task 2.

* refactor(artwork): defer fs.File close until after taglib reads finish

Mirror the lifetime pattern used by adapters/gotaglib/gotaglib.go:
keep the underlying fs.File open until taglib.File is closed, and
pass WithFilename so format detection doesn't rely on content sniffing.

* docs(artwork): note ffmpeg's path-based API limitation

* refactor(artwork): migrate album reader to MusicFS

- Add libFS (storage.MusicFS) field to albumArtworkReader; resolved
  once at construction time via libraryFS()
- Switch fromCoverArtPriority from abs-path shims to FS-based
  fromTag/fromExternalFile; only fromFFmpegTag retains absolute path
- Build imgFiles as library-relative forward-slash paths in
  loadAlbumFoldersPaths using path.Join(f.Path, f.Name, img)
- Guard embedAbs so that an empty EmbedArtPath never produces a
  non-empty absolute path (prevents accidental ffmpeg invocation)
- Register testfile:// storage scheme in artwork test suite to provide
  an os.DirFS-backed MusicFS without requiring the taglib extractor
- Update test assertions from filepath.FromSlash(abs) to bare
  forward-slash relative strings

* fix(artwork): use path package in compareImageFiles for forward-slash relative paths

* refactor(artwork): migrate disc reader to MusicFS

Replace os.Open absolute-path access with libFS.Open on library-relative
forward-slash paths. Rename discFolders→discFoldersRel, split
firstTrackPath into firstTrackRelPath (for fromTag) and firstTrackAbsPath
(for fromFFmpegTag), and switch path.Dir/Base/Ext for forward-slash safety.

* refactor(artwork): build discFoldersRel directly and guard empty first track

* refactor(artwork): migrate mediafile reader to MusicFS

* refactor(artwork): migrate artist album-art lookup to MusicFS

* refactor(artwork): remove temporary path-based shims

All readers now use the FS-based fromTag and fromExternalFile directly,
so the absolute-path adapters and the osDirectFS helper that backed
them can go away.

* test(artwork): rewrite e2e suite to use storagetest.FakeFS

Switches from real-tempdir + local storage to FakeFS via the storage
registry. Adds a proper multi-disc scenario using the disc tag, which
previously required curated MP3 fixtures we did not have.

* test(artwork): use maps.Copy in trackFile tag merge

Lint cleanup: replace the manual map-copy loop flagged by mapsloop.

* test(artwork): reuse tests.MockFFmpeg in e2e harness

Replace the hand-rolled noopFFmpeg stub with tests.NewMockFFmpeg, which
already satisfies the full ffmpeg.FFmpeg interface and won't drift when
new methods are added. Also tie imageBytes to imageFile so they cannot
silently disagree on the on-disk encoding.

* test(artwork): add e2e scenarios from artwork documentation

Covers the behaviors documented at
https://www.navidrome.org/docs/usage/library/artwork/:

- Album: folder.*/front.* fallbacks and priority order with cover.*.
- Disc: cd*.* match, cover.* inside disc folder, DiscArtPriority="" skip
  path, the documented multi-disc layout, and the discsubtitle keyword.
- MediaFile: disc-level fallback for multi-disc tracks and album-level
  fallback for single-disc tracks (doc section "MediaFiles" items 2-3).
- Artist: album/artist.* lookup via libFS (passes). The artist-folder
  branch is XIt-marked because fromArtistFolder still calls os.DirFS
  directly on an absolute path and can't read from a FakeFS-backed
  library — migrating that to storage.MusicFS is a follow-up.

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

* refactor(artwork): scope artist folder traversal to library root

Route fromArtistFolder reads through storage.MusicFS and bound the
parent-directory walk at the library root. This keeps artwork
resolution scoped to the configured library and unblocks FakeFS-backed
e2e scenarios that depend on the artist folder.

Also consolidate the libraryFS + core.AbsolutePath pairing (used by
three readers) into a single libraryFSAndRoot helper.

* test(artwork): add ASCII file-tree diagrams to e2e scenarios

Each It/PIt block now shows the on-disk layout it exercises, with
arrows indicating which file wins (or should win, for the known-bug
PIt cases). Makes scenarios readable at a glance without having to
parse the MapFS map.

* test(artwork): add e2e tests for playlist and radio artwork resolution

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

* test(artwork): enhance e2e tests with real MP3 fixtures for embedded artwork

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

* test(ffmpeg): add support for animated WebP encoder detection and fallback handling

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

* test(artwork): cover additional edge cases in e2e suite

Add high-value scenarios uncovered by the existing specs:

- Album: three-way basename tie (unsuffixed wins), unknown pattern in
  CoverArtPriority is skipped, embedded-first with no embedded art
  falls through.
- Disc: discsubtitle with no matching image falls through.
- Artist: ArtistArtPriority can reach images via album/<pattern>.
- Playlist: generates a 2x2 tiled cover from album art when the playlist
  has no uploaded/sidecar/external image.

New helper realPNG() produces real taglib/image-decodable bytes so the
tiled-cover test can exercise the generator's decode + compose path.

* test(artwork): refactor image upload logic in e2e tests for consistency

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

* test(ffmpeg): simplify animated WebP encoder check by removing context parameter

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

* fix(artwork): normalize rel path for fs.Glob on Windows

filepath.Rel returns backslash-separated paths on Windows, but fs.Glob
and path.Join require forward slashes. Convert with filepath.ToSlash
after computing the relative path and use path.Dir for the parent walk
so the artist-folder lookup works cross-platform.

* fix(ffmpeg): retry animated WebP probe on transient failure

The probe previously used the caller's request context inside sync.Once,
so a single cancelled first request would permanently disable animated
WebP for the rest of the process. Switch to a mutex + probed flag, use
a fresh background context with its own timeout, and only cache the
result when the probe actually succeeds.

* test(ffmpeg): reset ffOnce so ConvertAnimatedImage test is order-independent

The ConvertAnimatedImage stand-in test sets ffmpegPath directly but
does not reset ffOnce. If ffmpegCmd() has not been called earlier in
the test process, the next call inside hasAnimatedWebPEncoder runs
ffOnce.Do and re-resolves the real ffmpeg binary, overwriting the
stand-in and breaking the test. Reset ffOnce and conf.Server.FFmpegPath
alongside the other globals to pin resolution to the stand-in.

* test(artwork): unblock Windows CI — forward-slash fs paths and suite-level DB lifetime

The internal artwork test planted a Windows absolute path (backslashes) into
Folder.Path and then fed it through libFS.Open, which fs.ValidPath rejects.
Rooting the testfile library at the temp dir directly and using
filepath.ToSlash keeps the path model library-relative and forward-slash,
matching production.

The e2e suite opened a per-spec DB in a per-spec TempDir, but the go-sqlite3
singleton kept the file open across specs. Ginkgo's per-spec TempDir cleanup
then tried to unlink a file still held by that handle — fine on POSIX, fails
on Windows. Moving the DB to a suite-level tempdir and closing it in
AfterSuite avoids the race.

* test(artwork): keep Windows drive letters intact in testfile library URLs

url.Parse on `testfile://C:/path` reads `C` as the host and the path loses
the drive letter, so Windows libFS lookups go to `/path` and fail.
testFileLibPath now prepends a `/` when the OS path has no leading slash,
and the testfile constructor strips that extra slash back off before
handing the path to os.Stat / os.DirFS.

* refactor(artwork): consolidate libFS + root into libraryView helper

Collapses the per-reader libFS/libPath/rootFolder/firstTrackAbsPath fields
into a single libraryView{FS, absRoot} with an Abs(rel) method. Also folds
the two library lookups (ds.Library.Get + core.AbsolutePath) into one, and
uses mf.Path directly instead of stripping libRoot off an absolute path.

* refactor(ffmpeg): replace hasAnimatedWebPEncoder with encoderProbe for state management

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

* fix: escape artist folder names in artwork glob

Escape glob metacharacters in the library-relative artist folder path before composing the fs.Glob pattern for artist image lookup. This preserves literal folder names such as Artist [Live] while keeping the configured filename pattern behavior unchanged, and adds a regression test for bracketed artist folders.

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

* fix(artwork): correct test path assertions after MusicFS migration

Source functions (fromTag, fromExternalFile) now return forward-slash
fs.FS-relative paths, so test assertions should compare against plain
forward-slash strings, not filepath.FromSlash(). The artistArtPriority
test needs filepath.FromSlash() on the suffix because findImageInFolder
returns OS-native absolute paths via filepath.Join.

* fix(artwork): normalize path separators in artistArtPriority assertion

The two table entries exercise different code paths: entry 1 goes through
fromArtistFolder (returns OS-native paths via filepath.Join), while entry 2
goes through fromExternalFile (returns forward-slash fs.FS paths). Using
filepath.FromSlash on the expected value only works for entry 1.

Normalize the actual path to forward slashes with filepath.ToSlash so a
single HaveSuffix assertion works for both code paths on all platforms.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-26 18:16:14 -04:00