Commit Graph

519 Commits

Author SHA1 Message Date
Deluan Quintão
cc315dcc8c perf(db): keep query planner statistics trustworthy with full ANALYZE (#5740)
* perf(db): keep query planner statistics trustworthy with full ANALYZE

PRAGMA optimize's internal ANALYZE runs with a limited analysis budget
(~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality
indexes: on a 96K-track library it claimed (missing, library_id) narrows
to ~2000 rows when it matches the whole table. The planner then prefers
that index over the sort index and falls back to a full-table temp
B-tree sort per request, turning paginated song listings into
multi-second queries (reproduced at 5.5s on real hardware; ~90x slower
than with correct stats). Every index-creating migration re-triggered
the poisoning via the post-migration optimize, and the daily optimizer
could re-trigger it on large library changes. Setting analysis_limit on
the connection does not help: optimize ignores it.

Run a plain full ANALYZE instead: after migrations with schema changes,
and in db.Optimize (daily schedule and scan-end). Stats are stored in
the database file, so one connection suffices and the per-connection
pool loop is gone. The Optimize call at shutdown is removed: stats are
maintained at migration/scan/daily points, and an ANALYZE during
shutdown only delays it and races container stop timeouts.

* perf(db): drop startup PRAGMA optimize that re-poisons planner stats

The startup PRAGMA optimize=0x10002 runs SQLite's budget-limited internal
ANALYZE (bit 0x02), which writes truncated sqlite_stat1 rows for
low-cardinality indexes -- the exact statistics-poisoning this PR set out to
eliminate. Because DevOptimizeDB defaults to true, a restart with no pending
migrations would re-poison the planner until the next scan or daily Optimize.

Remove it: statistics are already refreshed with a full ANALYZE after
schema-changing migrations (Init) and via Optimize at scan-end and on the daily
schedule, so nothing on the startup path needs to touch them. Also clarify that
Optimize is a no-op unless DevOptimizeDB is enabled.

* chore(db): remove the DevOptimizeDB flag and skip Optimize on quick scans

The flag only gated the optimize/ANALYZE maintenance calls and there is
no reason to leave planner statistics unmaintained; the guards are gone
along with the flag. The scan-end Optimize now runs only after full
scans — quick scans barely move the statistics, and the daily schedule
covers drift.

* style(scanner): drop redundant comment in runOptimize

* chore(persistence): drop the no-op PRAGMA optimize from ScanEnd

Mask 0x10000 only selects candidate tables by size change; without the
0x02 action bit optimize does nothing (verified: sqlite_stat1 stays
stale after a 100x table growth). The scan-end statistics refresh is
db.Optimize's full ANALYZE, and the expression-collation-index concern
the old comment guarded against no longer applies.

* fix(scanner): run the post-scan ANALYZE in the server process

With the external scanner (the default), the scan pipeline runs in a
subprocess, so its ANALYZE was invisible to the server: SQLite loads
sqlite_stat1 into the process's shared schema cache, and an ANALYZE from
another process does not refresh it — verified with the production DSN
that even brand-new pool connections keep planning with the old
statistics until the server restarts. An in-process ANALYZE, by
contrast, is immediately visible to every pooled connection through the
same shared cache.

Move the full-scan Optimize from the scanner pipeline to the scan
controller, which always runs in the server process.

* fix(scanner): honor promoted full scans in the optimize gate

A quick scan resuming an interrupted full scan is promoted inside the
scanner (possibly in a subprocess); mirror the promotion in the
controller so the post-scan ANALYZE isn't skipped.

* refactor: apply cleanup review findings

- drop forceFullRescan's inline ANALYZE: Init already runs a full
  ANALYZE after any migration batch with schema changes, so upgrades
  including a full-rescan migration analyzed the whole DB twice
- resumingFullScan uses a filtered CountAll instead of fetching and
  scanning all libraries
- document why CallScan (CLI) deliberately skips the post-scan Optimize

* perf(db): make planner analysis maintenance resilient

Check analysis freshness every 30 minutes and refresh statistics when the last successful run is over 24 hours old or a scan marked them pending. Persist successful analysis state, retry skipped or failed maintenance, coordinate checks with scans, and cover standalone CLI full scans.

* perf(db): avoid analyzing routine quick-scan changes

Reserve pending analysis for full scans, unscanned libraries, and retry state. Incremental quick scans now rely on the 24-hour freshness window instead of triggering a full ANALYZE at the next maintenance check.

* fix(scan): analyze resumed full scans in CLI

* fix(db): back off failed analysis retries

* feat(db): allow disabling scheduled analysis

* test(db): remove redundant analysis coverage

* refactor(db): split ANALYZE maintenance into optimize.go and dedupe call sites

- move query-planner statistics code from db.go to its own optimize.go
  (and matching optimize_test.go)
- log ANALYZE elapsed time inside Optimize/OptimizeIfNeeded instead of
  repeating the timing block at every call site
- drop the LastDBAnalyzeAttemptAt write on success: it is only read
  while failures >= 1, and every failure rewrites it first
- extract runPostScanAnalysis (cmd) and anyIncludedLibrary (scanner)
  helpers
2026-07-13 12:04:29 -04:00
Deluan Quintão
116a440718 test(scanner): fix flaky Windows search_normalized rescan test (#5758)
The 'repopulates a stale search_normalized on a full rescan' spec runs
two full scans back-to-back. Whether the second scan refreshes the
unchanged artist depends on folderEntry.isOutdated(), which compares
folder.updated_at (written during the first scan) against the second
scan's library.last_scan_started_at using a strict time.Before(). Both
are time.Now() values captured milliseconds apart.

On Linux's fine-grained clock they are always distinct, so the test
passes. On Windows the coarse wall-clock granularity frequently makes
the two timestamps land in the same tick and compare equal, so
Before() returns false, the folder is treated as up-to-date and
skipped, the artist is never re-persisted, and search_normalized stays
empty -- failing the assertion intermittently across unrelated PRs.

Backdate the folder's updated_at an hour before the second scan so the
comparison is unambiguous on every platform. This is a test-only
timing artifact (real rescans never run milliseconds apart on an
unchanged library), so no production code changes are needed.
2026-07-10 18:43:31 -04:00
Deluan Quintão
7fa13761d7 fix(scanner): resolve file symlinks with the production local storage FS (#5755)
* fix(scanner): resolve file symlinks with the production local storage FS

The symlink classification added for GHSA-r5qr-m328-qcf4 relied on
fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS
interface, hiding its ReadLinkFS implementation. Every resolution failed
at the first hop, so the scanner silently skipped ALL file symlinks,
regardless of target or the FollowSymlinks setting. Libraries made of
symlinks (e.g. shared-pool setups) lost all their tracks after
upgrading to 0.63.

The local storage now exposes full OS-level resolution (EvalSymlinks)
through a new optional storage.SymlinkResolverFS interface, which the
scanner prefers over the fs.ReadLink hop loop. This also classifies a
chain by its FINAL target even when it passes through an audio-named
intermediate outside the library, closing a bypass the hop loop had.

Regular (non-symlink) entries keep the same early-return path, so scan
performance is unaffected for normal libraries.

Fixes #5752

* fix(test): keep watcher specs off the real local storage

The watcher specs spawn watchLibrary goroutines that are not joined on
spec teardown. Now that the scanner test binary registers the file://
storage, those leaked goroutines reached newLocalStorage, which reads
conf.Server on construction, racing with the configtest cleanup that
restores the config snapshot (caught by CI's race detector). Point the
mock libraries at a fake storage scheme, which never touches the config
and does not support watching, so the goroutine exits immediately.

* fix(storage): reject invalid fs paths in ResolveSymlink

Defense-in-depth for the SymlinkResolverFS contract: names must be valid
fs.FS paths. A lexical ".." in the name would otherwise escape the
library root via filepath.Join cleaning. No current caller can produce
such a name (they come from ReadDir walks), but the guard enforces the
documented contract at the boundary.
2026-07-10 10:52:05 -04:00
Deluan Quintão
427d4b9bce fix(search): artists with atomic non-ASCII names unfindable after FTS5 migration (#5703)
* fix(scanner): update artist search_normalized when rescanning

The FTS5 migration back-fills artist.search_normalized with a SQL
punctuation-strip approximation, relying on the next scan to compute the
precise value in Go (normalizeForFTS transliterates atomic letters like
Ø/æ/ß that FTS5's remove_diacritics cannot fold). But the scanner
persisted artists with an explicit column list that omitted
search_normalized, so not even a full scan ever repaired it: an artist
migrated from a pre-FTS database (e.g. "GØGGS") stayed unfindable by
any ASCII search, while their albums and songs, which are saved with all
columns, were fixed by a full scan. Add search_normalized to the column
list so a full scan re-indexes the artist via the artist_fts trigger.

* refactor(persistence): move normalizeForFTS to utils/str

Export it as str.NormalizeForFTS so the upcoming migration can reuse the
exact index-time normalization. Migrations cannot import the persistence
package (persistence -> db -> db/migrations would be an import cycle).

* fix(persistence): backfill artist search_normalized via migration

Recompute artist.search_normalized with the precise Go normalization for
databases migrated from pre-FTS5 versions, where the SQL back-fill could
not transliterate atomic letters (Ø/æ/ß) and the scanner never rewrote
the column. Only changed rows are updated, so the artist_fts update
trigger re-indexes exactly the affected artists, making artists like
GØGGS or MØ findable again without requiring a full scan.

* refactor(persistence): share FTS punctuation-strip regex via utils/str

Index-time normalization (NormalizeForFTS) and query-time processing
(buildFTS5Query/ftsQueryDegraded) must produce matching tokens, so keep
the punctuation-strip pattern in a single exported symbol instead of two
identical private copies that could drift. Also document that derived
columns computed in dbArtist.PostMapArgs must be listed in the scanner's
artist Put, which is how search_normalized went stale in the first place.

* chore(migrations): announce artist search backfill in the log

Match the FTS5 migration's notice() pattern so startup isn't silent
while the backfill runs on large libraries.

* docs: tighten comments added in this branch

* docs: describe FTSPunctStrip by what it matches, not one replacement
2026-07-02 12:53:10 -04:00
Deluan Quintão
01b7c86f90 fix(scanner): stop logging expected lyrics sniff misses as warnings (#5702)
* fix(scanner): stop logging expected lyrics sniff misses as warnings

During a scan, embedded lyrics are parsed with an empty suffix, which puts
ParseLyrics into content-sniffing mode: it tries the TTML, SRT and Lyricsfile
YAML parsers in turn before falling back to plain text. Every plain-text or LRC
lyric therefore fails the structured probes on its way to the fallback, and each
failure was logged at warning level with no indication of which file triggered
it, flooding the scan log with benign "Error parsing lyrics, falling back to
plain text" messages.

A probe rejecting content it does not own during sniffing is expected control
flow, so it is now logged at trace instead. A parse failure under an explicitly
requested suffix (e.g. a malformed .yaml/.srt/.ttml sidecar) still warns, since
the user declared that format. ParseLyrics gains ctx and path parameters so any
warning names the offending file and carries request context where available;
all call sites are updated accordingly.

Also fixes a test-isolation bug in the new logging spec: the BeforeEach swapped
the process-global default logger via SetDefaultLogger but only restored the log
level on cleanup, leaking the null logger and its hook into later specs in the
shared model suite.

* test: use spec-scoped contexts instead of context.Background in lyrics tests

Replace context.Background() with GinkgoT().Context() (and b.Context() in the
parse benchmarks) across the lyrics-related tests, so contexts are cancelled
when each spec ends. The embeddedLyrics fixture in core/lyrics is now a
hand-written literal like its sibling fixtures, removing the construction-time
ParseLyrics call that could not use a spec-scoped context.

* refactor(model): attach lyrics parse log attribution via context

Narrow ParseLyrics back to (ctx, suffix, lang, contents), dropping the path
parameter added by the previous commit. Attribution now uses the codebase's
existing idiom: callers that know the source attach it with log.NewContext
(e.g. "file" for the media file or sidecar), and the plugin adapter tags both
the plugin name and the track, fixing probe-miss logs that misattributed
plugin-returned content to the file's own tags. This removes three adjacent
string parameters that were easy to swap silently, and the "" placeholder most
call sites had to pass.

Also hardens the logging spec from the previous commit: the null test logger is
now swapped in before raising the level (SetLevel forces the current default
logger to trace, so the old order left the null logger at info and trace
entries never reached the hook), the sniff test now asserts probe misses are
observable at trace with file attribution instead of only asserting the absence
of warnings, and cleanup restores the actual previous logger — via a new return
value on log.SetDefaultLogger — instead of a bare logrus.New() that would
discard hooks configured on the process-wide logger.

* refactor(lyrics): hoist attributed log contexts out of loops

Address review feedback on #5702: build the log-attributed context once per
operation instead of per iteration, and reuse it on the surrounding log calls
so the error/trace lines around ParseLyrics carry the same attribution fields.
In fromExternalFile the sidecar path now rides the context for all log lines
in the function, replacing the repeated explicit "path" field.

* style(model): pass lyrics parse errors as final log arguments

Per the project logging convention, errors go as the last argument (the log
package normalizes them via its error case) instead of a keyed "error" pair,
which stores the raw error value and bypasses that handling. Flagged by review
on #5702; the keyed form was inherited from the original warning line.
2026-07-02 09:46:57 -04:00
Deluan Quintão
05105e91d9 feat(scanner): add Scanner.IgnoreDotFolders to allow indexing dot-prefixed folders (#5568)
* feat(scanner): add Scanner.IgnoreDotFolders to allow scanning dot folders

Adds a new Scanner.IgnoreDotFolders option (default true, preserving current
behavior) that, when disabled, lets the scanner traverse folders whose names
start with a dot, such as albums like ".Hack Sign Original Soundtrack".

Previously every dot-prefixed entry was skipped unconditionally before the
directory check, so such album folders were never indexed. The walk loop now
determines whether an entry is a directory first, then skips dot-prefixed files
always and dot-prefixed folders only when IgnoreDotFolders is enabled. Special
system directories are still ignored in all cases via the ignoredDirs blocklist,
which now also lists .git explicitly (it was previously caught only by the
generic dot-prefix rule). isDirIgnored is reduced to a pure blocklist check and
the name-only predicate is renamed from isEntryIgnored to isDotEntry.

* refactor(scanner): centralize entry ignore policy in isIgnoredEntry

Consolidates the directory-entry ignore decision into a single isIgnoredEntry
helper so the walk loop reads as pure dispatch (recurse into directories,
classify files) instead of interleaving ignore policy with traversal.

The dot-prefix rule and the ignoredDirs blocklist were previously checked in two
separate places inside loadDir's loop. They are now combined behind one helper
that takes the entry name and whether it is a directory. isDirIgnored remains a
standalone blocklist predicate because the file watcher (isIgnoredPath) calls it
directly. Adds focused unit tests for isIgnoredEntry covering both states of
Scanner.IgnoreDotFolders. No behavior change.

* fix(scanner): stop watcher from scanning ignored dot folders

A filesystem change inside a dot-prefixed folder (e.g. ".Hidden Album/track.mp3")
previously triggered a targeted scan of that folder, because isIgnoredPath let
all media files through and only checked the changed path's parent against the
ignore list (which never matched for nested paths due to the trailing separator).
With Scanner.IgnoreDotFolders enabled this caused the folder to be indexed even
though a full scan would skip it.

The watcher now ignores any change located inside an ignored directory via a new
isUnderIgnoredDir helper that reuses the same isIgnoredEntry policy as the scan
walk, and checks the entry itself with isIgnoredEntry instead of the parent dir.
This keeps the watcher and the scanner in agreement for both dot-folders (gated
by the flag) and the ignoredDirs blocklist. Adds direct table tests for
isIgnoredPath covering both states of the option.

* fix(scanner): exclude '.' from isDotEntry and ignore dot media files in watcher

Addresses code review feedback:

- isDotEntry now excludes the literal "." reference, matching its documentation.
  Previously isDotEntry(".") returned true, which could mark a path component as
  a dot-entry in the watcher.
- isIgnoredPath now ignores dot-prefixed media files (e.g. ".hidden.mp3") so the
  watcher matches the scanner, which always skips dot files. Non-media leaves
  still fall through to the directory-assumption check, so dot-folders continue
  to follow Scanner.IgnoreDotFolders.

Adds unit tests for isDotEntry and watcher coverage for dot-prefixed media files.

* docs(scanner): clarify isDotEntry multi-dot exclusion and add test

Expand the isDotEntry comment to explain why names with two or more
leading dots (".."/"..foo"/"...Album") are not treated as hidden, which
surprised a reviewer testing dot-folder scanning. Add a "..foo" test case
to make the two-leading-dots behavior explicit.

Claude-Session: https://claude.ai/code/session_012STiDTyhZAdH8JNtdNe8L1
2026-06-19 19:13:16 -04:00
Deluan Quintão
aa5aa731dc refactor(lyrics): single ParseLyrics entry point + all-format plugin lyrics (#5632)
* refactor(lyrics): read sidecar files via library storage FS

Routes fromExternalFile reads through storage.For(mf.LibraryPath) instead
of os.Open on AbsolutePath, fixing sidecar reads for non-local backends.
UTF-16 LE/BE and BOM handling preserved via ioutils.UTF8Reader.

* refactor(lyrics): address review feedback on sidecar FS read

- Move blank local-storage import from sources.go into lyrics_suite_test.go
  (the test suite already imports the local package for RegisterExtractor,
  so local's init() runs; production binaries get the scheme via normal wiring)
- Fix misleading comment: model.ParseLyrics → model.ParseLyricsFile
- Replace what-comment with why-comment in BeforeSuite explaining the
  log.Fatal guard that requires the no-op extractor registration

* test(lyrics): add subsonic e2e baseline for getLyrics endpoints

Establishes a behavioral baseline for getLyricsBySongId (v2 structured)
and getLyrics (legacy) before the lyrics parser refactor. Covers embedded
formats (LRC synced, plain text, TTML) and sidecar formats (LRC, SRT,
YAML), all isolated under a Lyrics/ fixture folder so the new fixtures
do not perturb existing test behavior beyond fixture counts.

Sidecar files are injected as raw &fstest.MapFile{Data: []byte(...)}
entries; the scanner skips non-audio extensions (.lrc, .srt, .yaml) so
they are invisible to scanning but reachable via the fake FS at request
time through fromExternalFile/storage.For.

Update album/artist/song counts in the album-list, multi-library, and
search3 empty-query tests to reflect the six new tracks (1 new artist,
1 new album, 6 new songs).

* test(lyrics): strengthen e2e lyrics baseline (lang assertions, rename helper)

Rename the local helper `main` to `firstLyric` to avoid collision with the
reserved-feeling built-in name. Add `Lang` assertions to both embedded and
sidecar DescribeTable entries, locking the current observed values: "xxx"
(ISO 639-2 "no language specified") for all embedded and LRC/SRT sidecars,
and "eng" for the YAML sidecar (which explicitly sets `language: eng`).

* feat(lyrics): detect Lyricsfile YAML in content-sniffing

* feat(plugins): content-sniff plugin lyrics for all formats

Replace model.ToLyrics (LRC/plain only) with model.ParseEmbedded so plugin
responses are content-sniffed for TTML, SRT, YAML, LRC, and plain text.
ParseEmbedded returns a LyricList, so the loop now flattens multiple tracks
per response entry.

The test-lyrics WASM plugin gains a "ttml" format mode (configured via
pdk.GetConfig) that returns a minimal TTML document; rebuilt with the
standard Go wasip1 toolchain (GOOS=wasip1 GOARCH=wasm). A new Ginkgo test
asserts Synced==true and the exact cue value, which the old plain-text path
could not produce.

GetLyrics doc comment updated to reflect content-sniffing; a later task will
retarget it to ParseLyrics once that function is introduced.

* test(plugins): validate plugin lyrics auto-detect across all formats

The test-lyrics WASM plugin now supports per-format modes via the
"format" config key: ttml, srt, yaml, lrc, and plain, in addition to
the existing default plain-text response. The plugin is rebuilt with the
standard Go wasip1 compiler.

lyrics_adapter_test.go gains a DescribeTable covering all five formats,
asserting both Synced (the discriminator that proves correct format
detection) and the exact line value. This validates the full
auto-detect chain (TTML → SRT → YAML/Lyricsfile → LRC → plain) end-to-end
through the real plugin → adapter → parser flow.

* refactor(lyrics): consolidate parsers into model.ParseLyrics

* refactor(lyrics): retarget legacy callers to model.ParseLyrics

Pin suffix to ".lrc" to preserve byte-identical output for stored
plain/LRC text that was previously handled by the now-removed ToLyrics.

* test(lyrics): fix lyrics tests after parser consolidation

- Rewrite the YAML-fallback test to assert the correct design: a
  non-Lyricsfile .yaml sidecar returns as plain text and shadows
  lower-priority sources (rather than falling through to .lrc).
- Add LibraryPath + relative Path split to the three subsonic tests
  that read sidecar files via storage.For(), so they resolve against
  the correct fixtures directory.
- Register a no-op extractor in api_suite_test.go BeforeSuite so
  newLocalStorage does not fatal when storage.For is called during
  sidecar-lyrics tests.

* test(lyrics): add per-format ParseLyrics benchmarks

Baseline measurements (count=2 runs) on M2:

BenchmarkParseLyrics_LRC-8           	    5725	    178796 ns/op	  49.78 MB/s	  427877 B/op	     523 allocs/op
BenchmarkParseLyrics_Plain-8         	    5425	    230854 ns/op	  32.44 MB/s	  102508 B/op	      16 allocs/op
BenchmarkParseLyrics_EnhancedLRC-8   	    1942	    605893 ns/op	  17.66 MB/s	  860678 B/op	    4256 allocs/op
BenchmarkParseLyrics_SRT-8           	    3249	    373991 ns/op	  25.91 MB/s	 1113575 B/op	    4407 allocs/op
BenchmarkParseLyrics_TTML-8          	    1483	    813027 ns/op	  13.86 MB/s	 2198052 B/op	    8665 allocs/op
BenchmarkParseLyrics_YAML-8          	    1700	    678250 ns/op	  13.01 MB/s	 1235096 B/op	    8288 allocs/op
BenchmarkParseLyrics_SniffTTML-8     	    1525	    776482 ns/op	  14.51 MB/s	 2225448 B/op	    8681 allocs/op
BenchmarkParseLyrics_SniffSRT-8      	    2528	    451210 ns/op	  21.48 MB/s	 1157000 B/op	    4422 allocs/op
BenchmarkParseLyrics_SniffYAML-8     	    1333	    827152 ns/op	  10.67 MB/s	 1337195 B/op	    8718 allocs/op
BenchmarkParseLyrics_SniffLRC-8      	    2820	    413038 ns/op	  21.55 MB/s	  588934 B/op	    1812 allocs/op
BenchmarkParseLyrics_SniffPlain-8    	    2968	    409091 ns/op	  18.31 MB/s	  254470 B/op	    1491 allocs/op

Content-sniff path overhead: 1.5–15% depending on format.

* test(lyrics): use real public-domain fixtures for parser benchmarks

Replace synthetic benchmark payloads with 'Auld Lang Syne' (Robert Burns,
1788, public domain) rendered into every supported format (LRC, plain,
enhanced LRC, SRT, TTML, Lyricsfile YAML) so the numbers reflect realistic
content. Same song across formats makes per-format cost comparable.

Baseline (Apple M-series, -benchmem, real fixtures):
  LRC          ~28 us/op   42 KB   147 allocs
  Plain        ~23 us/op   18 KB    22 allocs
  EnhancedLRC  ~37 us/op   51 KB   374 allocs
  SRT          ~52 us/op  139 KB   581 allocs
  TTML        ~119 us/op  276 KB  1227 allocs
  YAML        ~142 us/op  193 KB  1732 allocs
  Sniff(LRC)   ~47 us/op   57 KB   237 allocs
  Sniff(TTML) ~122 us/op  282 KB  1250 allocs
  Sniff(YAML) ~186 us/op  218 KB  1847 allocs

Fixtures in tests/fixtures/lyrics/.

* fix(lyrics): preserve [] (not null) for empty lyrics in backfill migration

ParseLyrics returns nil for zero-line input (whitespace-only stored
lyrics). json.Marshal(nil LyricList) produces null, violating the DB
invariant that media_file.lyrics uses [] for empty lyrics, never null.
Initialize to model.LyricList{} when ParseLyrics returns nil so the
marshalled result is always [].

* refactor(lyrics): unify parser dispatch and centralize empty-list invariant

Apply thermo-nuclear review findings (behavior-preserving):

- Replace the suffix switch + three single-use closure adapters
  (parseTTMLKnown/parseSRTKnown + inline YAML closure) with a
  bySuffix map of a single lyricParser(lang, contents) signature.
  Normalize parseTTMLWithDefaultLang/parseSRTWithLanguage to that
  (lang, contents) order so no adapter glue is needed.
- Collapse the parallel sniffLyrics engine into one parseFirstMatch
  primitive shared by both the suffix and content-sniff paths
  (sniffOrder candidate list). TTML stays gated via parseTTMLIfDocument
  in sniff mode to avoid running the XML decoder on plain/LRC text.
- Add LyricList.MarshalJSON so empty/nil always serializes to [] (the
  lyrics column invariant), in one canonical place. Delete the
  migration's nil-guard, which the marshaler now subsumes.

Behavior verified unchanged: full suite + race + e2e green.

* refactor(lyrics): single registry drives both suffix dispatch and sniff order

Collapse the bySuffix map and sniffOrder slice into one ordered registry:
slice order is the content-sniff probe order, each row's suffixes drive
sidecar dispatch, and per-row bySuffix/byContent parsers preserve the
gated-TTML-when-sniffing distinction. One source of truth, no duplicated
parser references.

* refactor(lyrics): self-skipping parsers collapse the format table to one column

Move the TTML <tt>-document gate into parseTTMLWithDefaultLang itself (after
the encoding fixup, so UTF-16-declared docs are still recognized): non-TTML
content returns (nil, nil) to skip; a malformed <tt> document still errors.
SRT and Lyricsfile YAML already self-skip. With every structured parser
self-skipping, the format table drops to one {suffixes, parse} column named
lyricFormats — no bySuffix/byContent split, no separate sniff-only TTML gate.
Both the suffix and content-sniff paths share the same parser per format.

* refactor(lyrics): strip BOM once at ParseLyrics entry for all paths

Previously only the content-sniff path stripped the BOM; the suffix path
relied on its callers (fromExternalFile via UTF8Reader) having already
stripped it. That implicit contract was fragile — a caller passing raw
BOM-prefixed bytes with a suffix would reach the parsers with the BOM intact
(SanitizeText does not strip it). Strip once at entry so every path and
parser sees clean bytes regardless of caller. No-op for already-stripped
input.

* refactor(lyrics): trim verbose comments to essential why

* refactor(lyrics): move LRC parser to its own lyrics_lrc.go

Extract parseLRC, the enhanced-LRC helpers (parseEnhancedLine, adjustGroup,
stripEnhancedMarkers, shiftELRCCues), parseTime, and the LRC regexes from
lyrics.go into lyrics_lrc.go, with the parseLRC tests in lyrics_lrc_test.go.
This makes the layout symmetric — one file per format (lrc/srt/ttml/yaml) —
and leaves lyrics.go holding only shared types and cue normalization. All
moved symbols were already LRC-private; no behavior change.

* refactor(lyrics): collapse ParseLyrics suffix/sniff branches into one loop

Both modes differ only in which formats to try, so select candidates in a
single loop (all formats when sniffing, the suffix's own otherwise) and run
them through parseFirstMatch once. Drops the projected-slice make+index and
the ContainsFunc closure; unmatched suffixes yield no candidates and fall to
the plain-text floor, as before.

* refactor(lyrics): apply simplify-review cleanups

- stripBOM: bytes.TrimPrefix instead of []byte<->string round-trip (no alloc)
- ParseLyrics: pre-size the candidates slice
- move isTTMLDocument to lyrics_ttml.go beside its only caller (the dispatch
  layer should hold no per-format knowledge)

* refactor(lyrics): simplify test descriptions for structured lyrics

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

* refactor(lyrics): fold parseLyricsfile into lyricParser signature and rename file

- parseLyricsfile now matches the lyricParser signature directly (reads via
  bytes.NewReader), removing the parseLyricsfileBytes adapter and the
  string(contents) copy; the lyricFormats table references it directly.
- StructuredLyrics drops the vestigial LyricList{} init (json.Unmarshal
  overwrites; MarshalJSON owns the empty->[] invariant).
- Rename lyricsfile.go -> lyrics_lyricsfile.go (and its test) to match the
  lyrics_<format>.go convention used by lrc/srt/ttml.

* refactor(lyrics): move test-only parseTTML/parseSRT wrappers to test files

These zero-arg wrappers (defaulting lang to "xxx") had no production callers
after the consolidation — only the format tests used them. Move each beside
its tests so the production files carry no test-only code.

* build: exclude generated *_gen.go files from linting

The plugin host *_gen.go files (ndpgen output) were tripping the whitespace
linter despite carrying a generated marker. Exclude them by path so make lint
and the pre-push hook pass on untouched generated code.

* perf(lyrics): drop []byte/string round-trips in parsers

Apply code-review feedback to remove avoidable allocations in the lyrics
parsers. isTTMLDocument now takes []byte directly, so parseTTMLWithDefaultLang
no longer copies its buffer into a string before the TTML probe. parseSRTBlock
splits its block with strings.Split instead of converting to []byte and back
per line. ParseLyrics hoists strings.ToLower(suffix) out of the format loop.

No behavior change; the dropped len(scanner)==0 SRT guard was dead (strings.Split
never returns an empty slice, and the existing len(lines)==0 check still covers
empty input).

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

* refactor(lyrics): colocate and unexport cue-normalization helpers

Move the cue-normalization machinery out of lyrics.go into a dedicated
lyrics_normalize.go (with lyrics_normalize_test.go), leaving lyrics.go to hold
just the shared lyric types and their methods. lyrics.go was mixing the domain
type/contract definitions with format-agnostic post-processing.

Unexport normalizeLyrics, normalizeCueLines, and normalizeLineTiming: they have
no callers outside the model package, so they should not be part of its public
API. NormalizeCueEnds stays exported because the Subsonic enhanced-lyrics
serializer (server/subsonic/lyrics.go) resolves cue ends per agent group while
building the response; that is the only legitimate cross-package caller.

Also includes a small no-op robustness tweak in parseLRC: len(times) == 0
instead of times == nil (equivalent here, more idiomatic).

No behavior change.

* test(lyrics): add direct coverage for NormalizeCueEnds

NormalizeCueEnds is exported and carries the most intricate logic in the
normalization cluster (fill-from-next, fill-from-fallback, both clamps, and the
all-or-none clear), but was only exercised transitively. Add a focused spec
covering each branch plus the empty-input and no-mutation guarantees, bringing
the function to 100% coverage.

* test(lyrics): cover legacy getLyrics across formats and sources

Expand the legacy getLyrics e2e coverage from a single embedded-plain case to a
table over all six fixtures: embedded LRC/plain/TTML and sidecar LRC/SRT/YAML.

Each case asserts the v1 plain-text fallback contract — the structured lyric is
flattened to LRC-style plain text with no timing markup leaking through (no LRC
brackets, SRT arrows, or XML tags), regardless of the source format or whether
it is embedded or a sidecar file. This pins the behavior that synced TTML/SRT/
YAML formats degrade gracefully to plain text on the legacy endpoint.

* test(lyrics): cover songLyrics v1 vs v2 with word-level fixtures

Correct and expand the e2e lyrics coverage to match the OpenSubsonic songLyrics
extension contract:

- v1 (getLyricsBySongId, no enhanced): line-level lyrics with no cueLine, kind,
  or agents — even for word-level formats (ELRC, Lyricsfile YAML).
- v2 (getLyricsBySongId?enhanced=true): word-level cueLine surfaces for ELRC and
  YAML sources; kind="main" is set; a line-level source (SRT) still yields no
  cueLine even when enhanced.
- legacy getLyrics (artist/title): the original Subsonic endpoint, flattening any
  format to plain text. A prior commit mislabeled this as the "v1 contract";
  getLyrics predates OpenSubsonic and is unrelated to the extension versions.

Drive these with the public-domain tests/fixtures/lyrics files (the same set the
parser benchmarks use) so the e2e content stays in sync and actually carries the
word-level timing needed to distinguish v1 from v2. The embedded "synced LRC"
fixture is upgraded to ELRC (word-level); track counts are unchanged, so the
rest of the suite is unaffected.

* test(lyrics): parameterize v2 enhanced coverage across all formats

Convert the v2 (enhanced) e2e block from three ad-hoc cases into a DescribeTable
covering all six formats, matching the v1 and legacy tables. Each entry declares
whether the source carries word-level timing: ELRC, TTML, and Lyricsfile YAML
surface a cueLine; LRC, SRT, and plain text do not. All six get kind="main".

Add word-level <span> timing to the first line of the auld-lang-syne.ttml
fixture so TTML exercises the word-level cueLine path (the parser already
supports <span begin/end>, but the fixture was line-level only). The first line
now yields the same five word cues as the ELRC and YAML fixtures, keeping the
table assertions uniform across formats.

* fix(lyrics): honor caller language when Lyricsfile YAML omits it

parseLyricsfile discarded the caller's language argument, so a Lyricsfile YAML
parsed from an embedded tag or plugin response with no metadata.language was
labeled "xxx" even when ParseLyrics was given a language. The SRT and TTML
parsers already use the caller language as their default; fall back to it here
too, preferring the document's own metadata.language when present.

Also reword a misleading TTML comment: isTTMLDocument still runs an XML decode
(it stops at the first element), so the skip avoids the full TTML parse, not the
XML decoder entirely.

* refactor(lyrics): consolidate lyrics parsing functions names

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

* test(lyrics): drop test-only parse wrappers after parser rename

Commit 48c0173e8 renamed the production parsers to parseTTML/parseSRT, which
collided with the same-named test-only wrappers and broke the model test build
(parseTTML/parseSRT redeclared). Remove the wrappers and call the production
parsers directly with the placeholder language at each test site.

* test(lyrics): complete the truncated enhanced-LRC fixture

The auld-lang-syne.elrc fixture stopped after the first two stanzas (8 lyric
lines) while every other format fixture carries the full 24-line song. Extend it
to all 24 lines with per-word timing so it is a faithful enhanced-LRC sample and
the EnhancedLRC parser benchmark runs on a workload comparable to the others.
The first line's word timings are unchanged, so the e2e cueLine assertions still
hold.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-19 18:25:35 -04:00
Deluan
ecba19a08e fix(scanner): resolve symlinks to their target when classifying files
The scanner classified a file by the name of the directory entry, so a
symlink was treated as audio/image/playlist based on the link name rather
than what it actually points to. Now symlinks are fully resolved (following
the whole chain) and classified by the resolved target's extension, so a
symlink to a non-audio file is no longer imported as a track.

This also makes Scanner.FollowSymlinks apply to file symlinks, not just
directory symlinks as before. The default stays true, so following symlinks
to real audio files (second drives, shared folders, etc.) keeps working.

Adds trace logging for symlink resolution decisions and real-fs regression
tests covering multi-level symlink chains.
2026-06-18 15:48:29 -04:00
Deluan Quintão
838ceee26d perf(subsonic): speed up artist search3 deep-offset pagination (#5620)
* perf(subsonic): speed up artist search3 deep-offset pagination

Empty-query and FTS artist search (search3/search2) paginated via a
CROSS JOIN library_artist + DISTINCT in Phase 1 purely for library access
control. The DISTINCT forced a temp b-tree over the whole junction table on
every page, making deep offsets O(offset): ~200ms at offset 299k on 300k
artists.

Replace it with a join-free EXISTS predicate keyed on artist.id, backed by a
new covering index on library_artist(artist_id, library_id). EXISTS keeps
artist as the ordered driver and never fans out rowids, so Phase 1 stays a
plain ordered scan that LIMIT/OFFSET can short-circuit. Admin, headless, and
all-libraries users skip the filter entirely (the dominant case) for a flat
ordered walk over the primary key.

Measured on a 300k-artist / 1M-song library: admin/all-libs pagination is
~4.5-5.4x faster at depth (~180ms to ~33ms at offset 400k); restricted
subset users keep correct, gap-free pages while also getting faster.

The narrowing artist filter is applied at the subsonic layer only when the
request targets a strict subset of the user's libraries, so the common case
(and the admin fast-path) is never burdened with a redundant predicate.

* fix(subsonic): narrow artist search by library set, not count

narrowsArtistLibraries decided whether to add the subsonic-layer artist
narrowing filter by comparing len(requested) < len(accessible). musicFolderId
is not deduplicated, so duplicate IDs inflated the requested count: a user
requesting ?musicFolderId=1&musicFolderId=1&musicFolderId=2 against three
accessible libraries produced len([1,1,2])==3, which is not < 3, so the filter
was skipped and the user saw artists from the third library too.

Compare as set membership instead: the request narrows iff some accessible
library is absent from it (requested is always a subset of accessible, validated
upstream by selectedMusicFolderIds). This is immune to duplicate IDs. Add a
regression test that fails against the old length-based check.

Also consolidate the repeated EXISTS/no-DISTINCT/O(page) rationale that the
prior commit spread across five sites down to a single authoritative comment on
ArtistLibraryFilter, with the call sites referencing it.

* perf(subsonic): drop redundant library_artist covering index

The migration added an index on library_artist(artist_id, library_id) on the
theory that the restricted-subset artist-search EXISTS needed it to seek by
artist_id. Benchmarking on a 405k-artist / 5-library dataset showed no benefit:
the EXISTS subquery constrains both columns (artist_id = and library_id IN), so
SQLite already resolves it as a covering-index seek on the existing
(library_id, artist_id) UNIQUE autoindex. With the new index present the planner
still picks the autoindex and ignores it.

Drop the migration and correct the comment. Removing ~11MB of dead index plus
its write-amplification on every library_artist insert/delete, for zero query
gain.

* fix(scanner): mark artists missing when they lose their last library

Artist search Phase 1 filters on artist.missing and Phase 2 inner-joins
library_artist, so a non-missing artist with no library_artist row (an orphan)
takes a pagination slot in Phase 1 and then vanishes in Phase 2, shortening the
page and shifting deep offsets. The admin/headless search fast-path walks artist
unfiltered, so it is fully exposed to this.

Two paths created such orphans without updating artist.missing:
- RefreshStats deletes library_artist rows whose stats are '{}' (artist lost all
  content in a library) after every scan. This is the common source.
- Library deletion cascades away the library's library_artist rows.

Mark newly-orphaned artists missing at both sources, so the shared
'missing = false' search filter excludes them immediately instead of waiting for
a later scan. In RefreshStats the update only runs when the cleanup actually
removed rows (the only way a new orphan can appear), so steady-state scans pay
nothing; measured ~160ms on 300k artists only when orphans can exist.

* refactor(subsonic): address review feedback on artist search filter

Code-review follow-ups to the artist search pagination change:
- ArtistLibraryFilter: short-circuit to a constant-false predicate when no
  library IDs are given, avoiding a degenerate empty IN () subquery.
- ArtistLibraryFilter: add an inner LIMIT 1 to the correlated EXISTS so SQLite
  cannot flatten it into a fan-out join (an artist in multiple of the user's
  libraries would otherwise yield duplicate rowids and corrupt pagination).
- narrowsArtistLibraries: compare accessible-vs-requested as a set lookup
  instead of slices.Contains in a loop.
- searchConfig.LibraryFilter: document that a join-free filter is now a
  correctness requirement (DISTINCT was removed), not just a performance one.

* docs: trim verbose comments in artist search/orphan code

Condense the over-explained comments added in this PR to the essential 'why',
removing repeated cross-references and restatements of the adjacent code.

* fix(scanner): heal pre-existing orphan artists on full refresh

The orphan-marking added to RefreshStats only ran when its empty-stats cleanup
deleted rows, so it reconciled newly-created orphans but not ones already left
in the database by older versions (whose library_artist row was deleted before
this fix existed). Such legacy orphans would surface in the admin/headless search
fast-path as short/gappy pages.

Also run the orphan-marking on a full refresh (allArtists), so a full scan — which
upgrades commonly trigger and users can run manually — reconciles the backlog. No
migration needed; the runtime fixes prevent recurrence.

* perf(subsonic): extend artist search fast-path to all-library users

applyLibraryFilterToSearchQuery only skipped the library filter for admin and
headless processes. A regular (non-admin) user who can access every library has
the same result set as an admin, but was still given the EXISTS filter — an
O(offset) cost for a predicate that matches every non-missing artist anyway.

Skip the filter for them too, using a cheap library CountAll() (a count over the
tiny library table) compared against the user's library count. On any error it
falls back to the filtered path, which is correct, just slower.

* fix(scanner): log error as trailing arg, not explicit error key

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

* test(scanner): e2e guard for orphan artists under PurgeMissing

Adds an end-to-end scanner test for the orphan-artist invariant fixed in
RefreshStats: with Scanner.PurgeMissing enabled, removing all of an artist's
files hard-deletes them, cascades away their media_file_artists rows, and
RefreshStats then drops the artist's emptied library_artist row. The test
asserts no non-missing artist is left without a library_artist row. Verified it
fails without the RefreshStats orphan-marking and passes with it.

* test(scanner): assert the orphaned artist is marked missing

The orphan e2e test only checked the aggregate no-orphan invariant
(orphanCount == 0), which a fully-deleted artist or an un-cleaned row would also
satisfy — so it could pass without exercising the fix. Assert Pink Floyd's row
specifically: missing=false before, missing=true after, and absent from the
non-missing results. Verified it fails without the RefreshStats orphan-marking.

* test(scanner): drop misleading non-missing-list assertion for orphan

GetAll has no default missing filter, but selectArtist inner-joins library_artist,
so an orphaned artist (no junction row) is excluded from the results whether or
not it is marked missing. The Not(ContainElement) check therefore passed for the
wrong reason. The direct floydMissing() == 1 query is the assertion that actually
validates the missing flag; keep that plus the orphan-count invariant and an
over-marking guard on The Beatles.

* test(scanner): document why orphan check reads the artist row directly

Clarify that GetAll cannot observe the orphan: selectArtist inner-joins
library_artist, so an artist with no junction row is excluded from results
whether or not it is marked missing. Asserting on GetAll would pass even without
the fix, so the test reads the artist row directly to check the missing flag.

* test(scanner): return descriptive artist state for clearer failures

floydState returns PRESENT/MISSING/NOT_FOUND instead of 0/1/-1, so a failure
reads '<string>: PRESENT to equal MISSING' rather than '0 to equal 1'.

* refactor(subsonic): keep artist library scoping in the repository

The search endpoint built a persistence-layer EXISTS predicate
(persistence.ArtistLibraryFilter) and injected it into artistOpts.Filters — the
only place the subsonic package reached into persistence, leaking a storage
detail up two layers.

Pass the same Eq{"library_id": ids} filter used for albums and songs, and let
the artist repository translate it to the join-free library_artist predicate
(scopeSearchToLibraries), where the junction knowledge belongs. The subset-vs-
fast-path decision moves there too, so narrowsArtistLibraries and the persistence
import are gone from the subsonic layer. Behavior is unchanged; coverage for the
translation moves to artist_repository_test.

* refactor(persistence): extract canonical markOrphansMissing helper

The 'mark non-missing artists with no library_artist row as missing' invariant
was hand-written as SQL in two places (RefreshStats and libraryRepository.Delete),
in two slightly different dialects (not exists vs id not in). Extract a single
artistRepository.markOrphansMissing method next to markMissing and call it from
both sites, so the invariant has one definition.

* fix(persistence): apply scoped library filter in both search phases

Two bugs from moving the artist library-scoping into the repository:

- Search() scoped opts.Filters for Phase 1 but still passed the original
  (unscoped) options to selectArtist, so Phase 2 re-applied the raw
  Eq{library_id} against the wrong columns and a restricted user's search
  returned nothing. Pass the scoped opts to both phases.

- scopeSearchToLibraries dropped the filter unconditionally for admins, so an
  admin explicitly narrowing via musicFolderId (e.g. search3?musicFolderId=2)
  leaked content from other libraries. Compare the request against the user's
  visible library set (all libraries for admin/headless), narrowing whenever it
  is a strict subset.

Both regressions were caught by the server/e2e multi-library suite.

* fix(core): delete library and reconcile orphans in one transaction

libraryRepository.Delete runs the FK-cascade delete and the orphaned-artist
reconciliation (markOrphansMissing) as two writes on r.db. Called directly they
autocommit separately, so an interruption between them could leave non-missing
artists with no library_artist row — the orphan state the artist search
fast-path forbids. Wrap the deletion in ds.WithTx at the core wrapper so both
writes commit atomically; the watcher/scanner/broker side-effects stay
post-commit.

* refactor(persistence): unify artist search library scoping into one filter

Phase 1 previously applied two overlapping library predicates: cfg.LibraryFilter
(scoped to the user's libraries) AND options.Filters (the requested subset),
producing two correlated EXISTS subqueries per rowid even though the request is
always a subset of the user's libraries. And the 'does this user see everything'
decision was implemented twice (userHasAllLibraries via CountAll vs
scopeSearchToLibraries via set-membership), with applyLibraryFilterToSearchQuery
as a third scoping path.

Resolve the effective library scope once in Search() via searchScope (intersect
the requested set with the user's visible libraries; nil = fast-path), clear
opts.Filters, and realize that single scope as the only Phase-1 LibraryFilter.
The visibility logic is now one pipeline: requestedLibraryIDs + visibleLibraryIDs
+ userSeesAllLibraries. Behavior unchanged; one EXISTS instead of two on the hot
path, one source of truth for library visibility.

* fix(persistence): harden artist search against malformed library_id filter

Search consumed only an Eq{"library_id": []int} filter; an Eq whose library_id
value wasn't []int slipped through unconsumed and would reach Phase 1's bare
artist table (no library_id column) → SQL error. Recognize any Eq carrying a
library_id key (isLibraryIDFilter) and always consume it, falling back to the
user's visible scope for a malformed value. Non-library filters are still left
in place for doSearch.

* refactor(persistence): trim redundant comments and unexport artist library filter

The artist-search-pagination work left dense explanatory comments, with the
join-free / LIMIT-1 anti-flatten rationale and the orphan-artist mechanics each
restated in several places. Consolidate each rationale into one canonical home
(artistLibraryFilter for the EXISTS/LIMIT-1 trick, markOrphansMissing for the
orphan lifecycle) and have the other sites reference it instead of repeating it.

Also unexport ArtistLibraryFilter to artistLibraryFilter: its only caller is
searchCfg in the same package and no test references it, so it never needed to
be part of the package's exported surface.

Comments only plus the rename; no behavior change.

* refactor: add slice.ToSet and use it for the artist search subset check

searchScope's subset test compared the requested libraries against the visible
set with a nested slices.Contains, which is O(visible * requested). On an instance
with many libraries (e.g. 100 libraries, a user granted 99) and an explicit
musicFolderId request, that is ~9.8k comparisons; with a set it is ~200.

Add a small reusable slice.ToSet helper (a slice -> map[T]struct{} set, collapsing
duplicates) and use it to make the membership lookups O(1), restoring O(n+m) without
the throwaway struct{}{} literal that an inline ToMap would need. No behavior change.

* refactor(artist): move artistLibraryFilter to artist_repository

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-16 21:47:15 -04:00
Deluan Quintão
2c90685bc2 fix(scanner): import playlists skipped when no admin existed yet (#5609)
* fix(scanner): import playlists skipped when no admin existed yet (#5499)

On a fresh install the first scan runs before any admin user exists, so the
scanner's playlist phase skips all playlists (playlists are owned by the
first admin). Nothing re-imported them afterwards because folder selection
is gated on updated_at > last_scan_at, which nothing bumps.

The playlist phase now:
- resolves the admin at phase time (FindFirstAdmin) instead of trusting the
  context snapshot taken at scan start, so a long admin-less scan still
  imports playlists in its own phase if an admin was created meanwhile;
- records a persisted PlaylistsImportPending flag when no admin exists yet;
- when that flag is set, imports ALL playlist folders via a new
  GetAllWithPlaylists (bypassing the timestamp gate) and clears the flag.

Playlists are recovered by the next scan that runs with an admin, with no
dependency on scan duration and no changes to the auth/server layers.

* fix(scanner): surface datastore errors in playlist import deferral (#5499)

Address review feedback:
- distinguish model.ErrNotFound (no admin yet -> defer) from real datastore
  errors when resolving the admin, so DB failures are propagated, not swallowed;
- propagate the error if the pending-import flag can't be persisted, so a scan
  doesn't complete as successful without recording the recovery;
- surface read errors when checking the pending flag.

Also name the no-admin condition for readability.

* fix(scanner): simplify admin existence check in playlist import

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

* fix(scanner): streamline folder access in playlist import logic

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 13:39:16 -04:00
Deluan
03ac02d964 refactor: more warnings clean up
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-20 17:43:12 -03:00
Deluan
efe9291db0 refactor: multiple syntax updates for Go 1.26
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-19 18:02:36 -03: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
5b85b2839a fix(scanner): fix error when importing playlists without an admin user
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 18:04:11 -04:00
Deluan
52099ce91f test: fix flaky watcher and scheduler tests on Windows CI
Replace timing-sensitive time.Sleep synchronization with proper
Eventually/Consistently assertions in watcher tests, and increase
Eventually timeouts from 200ms to 500ms. Add FlakeAttempts(3) to the
inherently timing-dependent tests. For the scheduler test, increase
the Eventually timeout from 1s to 5s for the cron job execution check.
2026-05-03 11:26:07 -04:00
Deluan Quintão
5d1c9530ab feat(cli): add pls export/import subcommands for bulk playlist management (#5412)
* refactor: rename ImportFile to ImportFromFolder in playlists service

* feat: add ImportFile method with library/folder resolution

* feat: allow sync flag upgrade on re-import of non-synced playlists

* feat: add pls export subcommand with bulk and single export

Add `navidrome pls export` command that supports:
- Single playlist export to stdout (-p flag only)
- Single playlist export to directory (-p and -o flags)
- Bulk export of all playlists to a directory (-o flag only)
- Filtering by user (-u flag)
- Automatic filename sanitization and collision detection

Also extracts findPlaylist helper from runExporter for reuse.

* feat: add pls import subcommand with sync flag support

* fix: improve error message for export without output directory

* test: add tests for ImportFile sync flag and sync upgrade behavior

* refactor: streamline export and import logic by removing redundant comments and improving library path matching

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

* feat: update ImportFile method to include sync flag for playlist imports

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

* feat: implement fetchPlaylists function to streamline playlist retrieval

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

* feat: replace inline filename sanitization with centralized utility function

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

* feat: refactor playlist import logic to consolidate sync handling and improve method signatures

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

* fix: address code review feedback on playlist import/export

- Fix duplicate playlist creation on non-sync re-import: only reconcile
  sync flag when the playlist was actually persisted (has an ID)
- Distinguish "not in any library" from real errors in resolveFolder
  using a sentinel error, so DB/folder errors propagate instead of
  falling back to ImportM3U
- Use bufio.Scanner in countM3UTrackLines instead of reading entire file

* feat: replace bufio.Scanner with UTF8Reader and LinesFrom utility for improved file reading

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

* fix: record path for outside-library imports to prevent duplicates

Files outside all libraries now go through updatePlaylist with the
absolute path recorded, so re-importing the same file updates the
existing playlist instead of creating a duplicate.

* refactor: name guard condition in updatePlaylist for readability

Extracted the compound boolean expression into a named local variable
`alreadyImportedAndNotSynced` to make the intent of the early-return
guard clearer at a glance.

* add godocs

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-25 20:54:02 -04:00
Deluan Quintão
64c8d3f4c5 ci: run Go tests on Windows (#5380)
* ci(windows): add skeleton go-windows job (compile-only smoke test)

* ci(windows): fix comment to reference Task 7 not Task 6

* ci(windows): harden PATH visibility and set explicit bash shell

* ci(windows): enable full go test suite and ndpgen check

* test(gotaglib): skip Unix-only permission tests on Windows

* test(lyrics): skip Windows-incompatible tests

* test(utils): skip Windows-incompatible tests

* test(mpv): skip Windows-incompatible playback tests

Skip 3 subprocess-execution tests that rely on Unix-style mpv
invocation; .bat output includes \r-terminated lines that break
argument parsing (#TBD-mpv-windows).

* test(storage): skip Windows-incompatible tests

Skip relative-path test where filepath.Join uses backslash but the
storage implementation returns a forward-slash URL path
(#TBD-path-sep-storage).

* test(storage/local): skip Windows-incompatible tests

Skip 13 tests that fail because url.Parse("file://" + windowsPath)
treats the drive letter colon as an invalid port; also skip the
Windows drive-letter path test that exposes a backslash vs
forward-slash normalisation bug (#TBD-path-sep-storage-local).

* test(playlists): skip Windows-incompatible tests

* test(model): skip Windows-incompatible tests

* test(model/metadata): skip Windows-incompatible tests

* test(core): skip Windows-incompatible tests

AbsolutePath uses filepath.Join which produces OS-native path separators;
skip the assertion test on Windows until the production code is fixed
(#TBD-path-sep-core).

* test(artwork): skip Windows-incompatible tests

Artwork readers produce OS-native path separators on Windows while tests
assert forward-slash paths; skip 11 affected tests pending a fix in
production code (#TBD-path-sep-artwork).

* test(persistence): skip Windows-incompatible tests

Skip flaky timestamp comparison (#TBD-flake-persistence) and path-separator
real-bugs (#TBD-path-sep-persistence) in FolderRepository.GetFolderUpdateInfo
which uses filepath.Clean/os.PathSeparator converting stored forward-slash paths
to backslashes on Windows.

* test(scanner): skip Windows-incompatible tests

Skip symlink tests (Unix-assumption), ndignore path-separator bugs
(#TBD-path-sep-scanner) in processLibraryEvents/resolveFolderPath where
filepath.Rel/filepath.Split return backslash paths incompatible with fs.FS
forward-slash expectations, error message mismatch on Windows, and file
format upgrade detection (#TBD-path-sep-scanner).

* test(plugins): skip Windows-incompatible tests

Add //go:build !windows tags to test files that reference the suite
bootstrap (testManager, testdataDir, createTestManager) which is only
compiled on non-Windows. Add a Windows-only suite stub that skips all
specs via BeforeEach to prevent [build failed] on Windows CI.

* test(server): skip Windows-incompatible tests

Skip createUnixSocketFile tests that rely on Unix file permission bits
(chmod/fchmod) which are not supported on Windows.

* test(nativeapi): skip Windows-incompatible tests

Skip the i18n JSON validation test that uses filepath.Join to build
embedded-FS paths; filepath.Join produces backslashes on Windows which
breaks fs.Open (embedded FS always uses forward slashes).

* test(e2e): skip Windows-incompatible tests

On Windows, SQLite holds file locks that prevent the Ginkgo TempDir
DeferCleanup from deleting the DB file. Register an explicit db.Close
DeferCleanup (LIFO before TempDir cleanup) on Windows so the file lock
is released before the temp directory is removed.

* test(windows): fix e2e AfterSuite and skip remaining scanner path test

* test(scanner): skip another Windows path-sep test (#TBD-path-sep-scanner)

* test(subsonic): skip timing-flaky test on Windows (#TBD-flake-time-resolution-subsonic)

* test(scanner): skip 'detects file moved to different folder' on Windows

* test(scanner): consolidate 'Library changes' Windows skips into BeforeEach

* test(scanner): close DB before TempDir cleanup to fix Windows file lock

* test(scanner): skip ScanFolders suite on Windows instead of closing shared DB

* ci: retrigger for Windows soak run 2/3

* ci: retrigger for Windows soak run 3/3

* ci: retrigger for Windows soak run 3/3 (take 2)

* test(scanner): skip Multi-Library suite on Windows (SQLite file lock)

* ci(windows): promote go-windows to blocking status check

* test(plugins): run platform-neutral specs on Windows, drop blanket Skip

* test(windows): make tests cross-platform instead of skipping

- subsonic: back-date submissionTime baseline by 1s so
  BeTemporally(">") passes under millisecond clock resolution
- persistence: sleep briefly between Put calls so UpdatedAt is
  strictly after CreatedAt on low-resolution clocks
- utils/files: close tempFile before os.Remove so the test works on
  Windows (where an open handle holds a file lock)
- tests.TempFile: close the handle before returning; metadata tests
  no longer leak the open file into Ginkgo's TempDir cleanup

Resolves Copilot review comments on #5380.

* test(tests): add SkipOnWindows helper to reduce boilerplate

Introduces tests.SkipOnWindows(reason) that wraps the 3-line
runtime.GOOS guard pattern used in every Windows-skipped spec.

* test(adapters): use tests.SkipOnWindows helper

* test(core): use tests.SkipOnWindows helper

* test(model): use tests.SkipOnWindows helper

* test(persistence): use tests.SkipOnWindows helper

* test(scanner): use tests.SkipOnWindows helper

* test(server): use tests.SkipOnWindows helper

* test(plugins): run pure-Go unit tests on Windows

config_validation_test, manager_loader_test, and migrate_test have no
WASM/exec dependencies and don't rely on the make-built test plugins
from plugins_suite_test.go. Let them run on Windows too.
2026-04-19 13:16:47 -04:00
Deluan Quintão
ab8a58157a feat: add artist image uploads and image-folder artwork source (#5198)
* feat: add shared ImageUploadService for entity image management

* feat: add UploadedImage field and methods to Artist model

* feat: add uploaded_image column to artist table

* feat: add ArtistImageFolder config option

* refactor: wire ImageUploadService and delegate playlist file ops to it

Wire ImageUploadService into the DI container and refactor the playlist
service to delegate image file operations (SetImage/RemoveImage) to the
shared ImageUploadService, removing duplicated file I/O logic. A local
ImageUploadService interface is defined in core/playlists to avoid an
import cycle between core and core/playlists.

* feat: artist artwork reader checks uploaded image first

* feat: add image-folder priority source for artist artwork

* feat: cache key invalidation for image-folder and uploaded images

* refactor: extract shared image upload HTTP helpers

* feat: add artist image upload/delete API endpoints

* refactor: playlist handlers use shared image upload helpers

* feat: add shared ImageUploadOverlay component

* feat: add i18n keys for artist image upload

* feat: add image upload overlay to artist detail pages

* refactor: playlist details uses shared ImageUploadOverlay component

* fix: add gosec nolint directive for ParseMultipartForm

* refactor: deduplicate image upload code and optimize dir scanning

- Remove dead ImageFilename methods from Artist and Playlist models
  (production code uses core.imageFilename exclusively)
- Extract shared uploadedImagePath helper in model/image.go
- Extract findImageInArtistFolder to deduplicate dir-scanning logic
  between fromArtistImageFolder and getArtistImageFolderModTime
- Fix fileInputRef in useCallback dependency array

* fix: include artist UpdatedAt in artwork cache key

Without this, uploading or deleting an artist image would not
invalidate the cached artwork because the cache key was only based
on album folder timestamps, not the artist's own UpdatedAt field.

* feat: add Portuguese translations for artist image upload

* refactor: use shared i18n keys for cover art upload messages

Move cover art upload/remove translations from per-entity sections
(artist, playlist) to a shared top-level "message" section, avoiding
duplication across entity types and translation files.

* refactor: move cover art i18n keys to shared message section for all languages

* refactor: simplify image upload code and eliminate redundancies

Extracted duplicate image loading/lightbox state logic from
DesktopArtistDetails and MobileArtistDetails into a shared
useArtistImageState hook. Moved entity type constants to the consts
package and replaced raw string literals throughout model, core, and
nativeapi packages. Exported model.UploadedImagePath and reused it in
core/image_upload.go to consolidate path construction. Cached the
ArtistImageFolder lookup result in artistReader to eliminate a redundant
os.ReadDir call on every artwork request.

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

* style: fix prettier formatting in ImageUploadOverlay

* fix: address code review feedback on image upload error handling

- RemoveImage now returns errors instead of swallowing them
- Artist handlers distinguish not-found from other DB errors
- Defer multipart temp file cleanup after parsing

* fix: enforce hard request size limit with MaxBytesReader for image uploads

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-15 22:19:55 -04:00
Deluan Quintão
55331b5fd9 fix(scanner): prevent duplicate tracks when multiple missing files match same target (#5183)
In processMissingTracks, matched tracks were not removed from the candidate
pool after being consumed by moveMatched. This allowed the same target track
to be paired with multiple missing tracks, creating duplicate non-missing
records with the same path. Track consumed matches in a usedMatched map so
each target is used at most once.

Fixes #5169
2026-03-14 00:07:21 -04:00
Deluan
0790f66627 fix(scanner): increase watcher channel buffers to prevent dropped filesystem events
When files were moved between libraries, the small channel buffers (size 1)
throughout the watcher pipeline caused backpressure that led to dropped
filesystem events. This meant only some of the affected folders were scanned,
preventing cross-library move detection from working correctly.

Increase all watcher channel buffers to 500 and switch to blocking sends
to ensure no filesystem events are silently dropped.
2026-03-12 17:07:34 -04:00
Deluan
3cd5d16b0a chore: upgrade golangci-lint to 2.11 and fix lint issues
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-06 19:23:47 -05:00
Deluan
3476be01f7 fix(scanner): handle nil mainCtx in Watcher to prevent panic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-01 10:50:24 -05:00
Deluan Quintão
7ad2907719 refactor: move playlist business logic from repositories to service layer (#5027)
* refactor: move playlist business logic from repositories to core.Playlists service

Move authorization, permission checks, and orchestration logic from
playlist repositories to the core.Playlists service, following the
existing pattern used by core.Share and core.Library.

Changes:
- Expand core.Playlists interface with read, mutation, track management,
  and REST adapter methods
- Add playlistRepositoryWrapper for REST Save/Update/Delete with
  permission checks (follows Share/Library pattern)
- Simplify persistence/playlist_repository.go: remove isWritable(),
  auth checks from Delete()/Put()/updatePlaylist()
- Simplify persistence/playlist_track_repository.go: remove
  isTracksEditable() and permission checks from Add/Delete/Reorder
- Update Subsonic API handlers to route through service
- Update Native API handlers to accept core.Playlists instead of
  model.DataStore

* test: add coverage for playlist service methods and REST wrapper

Add 30 new tests covering the service methods added during the playlist
refactoring:

- Delete: owner, admin, denied, not found
- Create: new playlist, replace tracks, admin bypass, denied, not found
- AddTracks: owner, admin, denied, smart playlist, not found
- RemoveTracks: owner, smart playlist denied, non-owner denied
- ReorderTrack: owner, smart playlist denied
- NewRepository wrapper: Save (owner assignment, ID clearing),
  Update (owner, admin, denied, ownership change, not found),
  Delete (delegation with permission checks)

Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and
rest.Persistable methods. Add mockedPlaylistTrackRepo for track
operation verification.

* fix: add authorization check to playlist Update method

Added ownership verification to the Subsonic Update endpoint in the
playlist service layer. The authorization check was present in the old
repository code but was not carried over during the refactoring to the
service layer, allowing any authenticated user to modify playlists they
don't own via the Subsonic API. Also added corresponding tests for the
Update method's permission logic.

* refactor: improve playlist permission checks and error handling, add e2e tests

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

* refactor: rename core.Playlists to playlists package and update references

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

* refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files

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

* fix: block track mutations on smart playlists in Create and Update

Create now rejects replacing tracks on smart playlists (pre-existing
gap). Update now uses checkTracksEditable instead of checkWritable
when track changes are requested, restoring the protection that was
removed from the repository layer during the refactoring. Metadata-only
updates on smart playlists remain allowed.

* test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions

* refactor: optimize track removal and renumbering in playlists

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

* refactor: implement track reordering in playlists with SQL updates

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

* refactor: wrap track deletion and reordering in transactions for consistency

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

* refactor: remove unused getTracks method from playlistTrackRepository

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

* refactor: optimize playlist track renumbering with CTE-based UPDATE

Replace the DELETE + re-INSERT renumbering strategy with a two-step
UPDATE approach using a materialized CTE and ROW_NUMBER() window
function. The previous approach (SELECT all IDs, DELETE all tracks,
re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track
playlist. The new approach uses just 2 UPDATEs: first negating all IDs
to clear the positive space, then assigning sequential positions via
UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations
that affected the original correlated subquery while reducing per-delete
request time from ~110ms to ~12ms on a 2000-track playlist.

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

* refactor: rename New function to NewPlaylists for clarity

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

* refactor: update mock playlist repository and tests for consistency

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 19:57:13 -05:00
Deluan
5fa8356b31 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:28:42 -05:00
Deluan Quintão
cad9cdc53e fix(scanner): preserve created_at when moving songs between libraries (#5055)
* fix: preserve created_at when moving songs between libraries (#5050)

When songs are moved between libraries, their creation date was being
reset to the current time, causing them to incorrectly appear in
"Recently Added". Three changes fix this:

1. Add hash:"ignore" to AlbumID in MediaFile struct so that Equals()
   works for cross-library moves (AlbumID includes library prefix,
   making hashes always differ between libraries)

2. Preserve album created_at in moveMatched() via CopyAttributes,
   matching the pattern already used in persistAlbum() for
   within-library album ID changes

3. Only set CreatedAt in Put() when it's zero (new files), and
   explicitly copy missing.CreatedAt to the target in moveMatched()
   as defense-in-depth for the INSERT code path

* test: add regression tests for created_at preservation (#5050)

Add tests covering the three aspects of the fix:
- Scanner: moveMatched preserves missing track's created_at
- Scanner: CopyAttributes called for album created_at on album change
- Scanner: CopyAttributes not called when album ID stays the same
- Persistence: Put sets CreatedAt to now for new files with zero value
- Persistence: Put preserves non-zero CreatedAt on insert
- Persistence: Put does not reset CreatedAt on update

Also adds CopyAttributes to MockAlbumRepo for test support.

* test: verify album created_at is updated in cross-library move test (#5050)

Added end-to-end assertion in the cross-library move test to verify that
the new album's CreatedAt field is actually set to the original value after
CopyAttributes runs, not just that the method was called. This strengthens
the test by confirming the mock correctly propagates the timestamp.
2026-02-17 08:37:05 -05:00
Deluan
fd09ca103f fix(scanner): resolve data race on conf.Server access in getScanner
Captured DevExternalScanner config value in the controller struct at
construction time instead of reading the global conf.Server pointer in
getScanner(). The background goroutine spawned by ScanFolders() was
reading conf.Server.DevExternalScanner concurrently with test cleanup
reassigning the conf.Server pointer, causing a data race detected by
the race detector in the E2E test suite.
2026-02-09 16:42:05 -05:00
Maximilian
a704e86ac1 refactor: run Go modernize (#5002) 2026-02-08 09:57:30 -05:00
Deluan
ebbc31f1ab fix(scanner): store scan errors in the database and update UI error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-01 16:18:26 +01:00
Deluan
fd620413b8 fix(tests): update goleak check condition to use GOLEAK environment variable
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 21:11:06 -05:00
Deluan Quintão
6d47a6ebd9 perf: optimize cross-library move detection for single-library setups (#4888)
* feat: skip cross-library detection for single library setup

When only one library is configured, skip the cross-library move detection stage entirely as there are no other libraries to search in. This eliminates unnecessary database queries - the primary performance issue reported by users (5-6 hour scans with 13.5k missing files).

Implementation:
- Added library count check in processCrossLibraryMoves
- Returns input unchanged when len(state.libraries) == 1
- Logs debug message for troubleshooting

* refactor: use lightweight queries for cross-library move detection

Replace selectMediaFile() with newSelect() in FindRecentFilesByMBZTrackID and FindRecentFilesByProperties. These queries only need basic media file columns for hash and path comparisons, not annotations/bookmarks.

Benefits:
- Removes unnecessary LEFT JOINs with annotation and bookmark tables
- Reduces query overhead for cross-library file matching
- Follows existing pattern used by GetMissingAndMatching

The annotation/bookmark joins are user-specific (using loggedUser context) and unused in cross-library matching logic where only Equals() and IsEquivalent() checks are performed.

* test: add coverage for single-library and multi-library cross-library detection

Add test cases to verify:
1. Single-library setup correctly skips cross-library move detection
2. Multi-library setup continues to process cross-library moves

Implementation:
- New test verifies processCrossLibraryMoves returns input unchanged for single library
- Wrapped existing multi-library tests in Context with multiple libraries setup
- Ensures no regressions in multi-library matching behavior

Tests verify:
- Single-library: no database queries, input passed through unchanged
- Multi-library: cross-library matching still works correctly
- Reduces the likelihood of introducing single-library skip bugs in future

* fix: enhance cross-library detection by introducing totalLibraryCount

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-15 17:22:46 -05:00
Deluan Quintão
03a45753e9 feat(plugins): New Plugin System with multi-language PDK support (#4833)
* chore(plugins): remove the old plugins system implementation

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

* feat(plugins): implement new plugin system with using Extism

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

* feat(plugins): add capability detection for plugins based on exported functions

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

* feat(plugins): add auto-reload functionality for plugins with file watcher support

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

* feat(plugins): add auto-reload functionality for plugins with file watcher support

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

* refactor(plugins): standardize variable names and remove superfluous wrapper functions

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

* fix(plugins): improve error handling and logging in plugin manager

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

* refactor(plugins): implement plugin function call helper and refactor MetadataAgent methods

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

* fix(plugins): race condition in plugin manager

* tests(plugins): change BeforeEach to BeforeAll in MetadataAgent tests

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

* tests(plugins): optimize tests

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

* tests(plugins): more optimizations

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

* test(plugins): ignore goroutine leaks from notify library in tests

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

* feat(plugins): add Wikimedia plugin for Navidrome to fetch artist metadata

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

* feat(plugins): enhance plugin logging and set User-Agent header

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

* feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities

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

* feat(plugins): integrate logs

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

* refactor(plugins): clean up manifest struct and improve plugin loading logic

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

* feat(plugins): add metadata agent and scrobbler schemas for bootstrapping plugins

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

* feat(hostgen): add hostgen tool for generating Extism host function wrappers

- Implemented hostgen tool to generate wrappers from annotated Go interfaces.
- Added command-line flags for input/output directories and package name.
- Introduced parsing and code generation logic for host services.
- Created test data for various service interfaces and expected generated code.
- Added documentation for host services and annotations for code generation.
- Implemented SubsonicAPI service with corresponding generated code.

* feat(subsonicapi): update Call method to return JSON string response

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

* feat(plugins): implement SubsonicAPI host function integration with permissions

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

* fix(generator): error-only methods in response handling

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

* feat(plugins): generate client wrappers for host functions

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

* refactor(generator): remove error handling for response.Error in client templates

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

* feat(scheduler): add Scheduler service interface with host function wrappers for scheduling tasks

* feat(plugins): add WASI build constraints to client wrapper templates, to avoid lint errors

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

* feat(scheduler): implement Scheduler service with one-time and recurring scheduling capabilities

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

* refactor(manifest): remove unused ConfigPermission from permissions schema

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

* feat(scheduler): add scheduler callback schema and implementation for plugins

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

* refactor(scheduler): streamline scheduling logic and remove unused callback tracking

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

* refactor(scheduler): add Close method for resource cleanup on plugin unload

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

* docs(scheduler): clarify SchedulerCallback requirement for scheduling functions

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

* fix: update wasm build rule to include all Go files in the directory

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

* feat: rewrite the wikimedia plugin using the XTP CLI

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

* refactor(scheduler): replace uuid with id.NewRandom for schedule ID generation

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

* refactor: capabilities registration

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

* test: add scheduler service isolation test for plugin instances

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

* refactor: update plugin manager initialization and encapsulate logic

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

* feat: add WebSocket service definitions for plugin communication

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

* feat: implement WebSocket service for plugin integration and connection management

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

* feat: add Crypto Ticker example plugin for real-time cryptocurrency price updates via Coinbase WebSocket API

Also add the lifecycle capability

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

* fix: use context.Background() in invokeCallback for scheduled tasks

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

* refactor: rename plugin.create() to plugin.instance()

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

* refactor: rename pluginInstance to plugin for consistency across the codebase

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

* refactor: simplify schedule cloning in Close method and enhance plugin cleanup error handling

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

* feat: implement Artwork service for generating artwork URLs in Navidrome plugins - WIP

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

* refactor: moved public URL builders to avoid import cycles

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

* feat: add Cache service for in-memory TTL-based caching in plugins

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

* feat: add Discord Rich Presence example plugin for Navidrome integration

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

* refactor: host function wrappers to use structured request and response types

- Updated the host function signatures in `nd_host_artwork.go`, `nd_host_scheduler.go`, `nd_host_subsonicapi.go`, and `nd_host_websocket.go` to accept a single parameter for JSON requests.
- Introduced structured request and response types for various cache operations in `nd_host_cache.go`.
- Modified cache functions to marshal requests to JSON and unmarshal responses, improving error handling and code clarity.
- Removed redundant memory allocation for string parameters in favor of JSON marshaling.
- Enhanced error handling in WebSocket and cache operations to return structured error responses.

* refactor: error handling in various plugins to convert response.Error to Go errors

- Updated error handling in `nd_host_scheduler.go`, `nd_host_websocket.go`, `nd_host_artwork.go`, `nd_host_cache.go`, and `nd_host_subsonicapi.go` to convert string errors from responses into Go errors.
- Removed redundant error checks in test data plugins for cleaner code.
- Ensured consistent error handling across all plugins to improve reliability and maintainability.

* refactor: rename fake plugins to test plugins for clarity in integration tests

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

* feat: add help target to Makefile for plugin usage instructions

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

* feat: add Cover Art Archive plugin as an example of Python plugin

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

* feat: update Makefile and README to clarify Go plugin usage

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

* feat: include plugin capabilities in loading log message

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

* feat: add trace logging for plugin availability and error handling in agents

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

* feat: add Now Playing Logger plugin to showcase calling host functions from Python plugins

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

* feat: generate Python client wrappers for various host services

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

* feat: add generated host function wrappers for Scheduler and SubsonicAPI services

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

* feat: update Python plugin documentation and usage instructions for host function wrappers

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

* feat: add Webhook Scrobbler plugin in Rust to send HTTP notifications on scrobble events

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

* feat: enable parallel loading of plugins during startup

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

* docs: update README to include WebSocket callback schema in plugin documentation

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

* feat: extend plugin watcher with improved logging and debounce duration adjustment

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

* add trace message for plugin recompiles

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

* feat: implement plugin cache purging functionality

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

* test: move purgeCacheBySize unit tests

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

* feat(plugins UI): add plugin repository and database support

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

* feat(plugins UI): add plugin management routes and middleware

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

* feat(plugins UI): implement plugin synchronization with database for add, update, and remove actions

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

* feat(plugins UI): add PluginList and PluginShow components with plugin management functionality

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

* feat(plugins): optimize plugin change detection

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

* refactor(plugins UI): improve PluginList structure

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

* feat(plugins UI): enhance PluginShow with author, website, and permissions display

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

* feat(plugins UI): refactor to use MUI and RA components

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

* feat(plugins UI): add error handling for plugin enable/disable actions

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

* refactor(plugins): inject PluginManager into native API

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

* refactor(plugins): update GetManager to accept DataStore parameter

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

* feat(plugins): add subsonicRouter to Manager and refactor host service registration

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

* refactor(plugins): enhance debug logging for plugin actions and recompile logic

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

* refactor(plugins): break manager.go into smaller, focused files

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

* refactor(plugins): streamline error handling and improve plugin retrieval logic

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

* refactor(plugins): update newWebSocketService to use WebSocketPermission for allowed hosts

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

* refactor(plugins): introduce ToggleEnabledSwitch for managing plugin enable/disable state

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

* docs: update READMEs

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

* feat(library): add Library service for metadata access and filesystem integration

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

* feat(plugins): add Library Inspector plugin for periodic library inspection and file size logging

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

* docs: update README to reflect JSON configuration format for plugins

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

* fix(build): update target to wasm32-wasip1 for improved WASI support

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

* feat(plugins): implement configuration management UI with key-value pairs support

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

* feat(ui): adjust grid layout in InfoRow component for improved responsiveness

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

* feat(plugins): rename ErrorIndicator to EnabledOrErrorField and enhance error handling logic

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

* feat(i18n): add Portuguese translations for plugin management and notifications

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

* feat(plugins): add support for .ndp plugin packages and update build process

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

* docs: update README for .ndp plugin packaging and installation instructions

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

* feat(plugins): implement KVStore service for persistent key-value storage

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

* docs: enhance README with Extism plugin development resources and recommendations

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

* feat(plugins): integrate event broker into plugin manager

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

* feat(plugins): update config handling in PluginShow to track last record state

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

* feat(plugins): add Rust host function library and example implementation of Discord Rich Presence plugin in Rust

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

* feat(plugins): generate Rust lib.rs file to expose host function wrappers

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

* refactor(plugins): update JSON field names to camelCase for consistency

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

* refactor: reduce cyclomatic complexity by refactoring main function

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

* feat(plugins): enhance Rust code generation with typed struct support and improved type handling

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

* feat(plugins): add Go client library with host function wrappers and documentation

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

* feat(plugins): generate Go client stubs for non-WASM platforms

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

* feat(plugins): update client template file names for consistency

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

* feat(plugins): add initial implementation of the Navidrome Plugin Development Kit code generator - Pahse 1

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 2 (2)

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 3

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 4

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

* feat(plugins): implementation of the Navidrome Plugin Development Kit with generated client wrappers and service interfaces - Phase 5

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

* refactor(plugins): consistent naming/types across PDK

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

* refactor(plugins): streamline plugin function signatures and error handling

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

* refactor(plugins): update scrobbler interface to return errors directly instead of response structs

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

* test: make all test plugins use the PDK

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

* refactor(plugins): reorganize and sort type definitions for consistency

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

* refactor(plugins): update error handling for methods to return errors directly

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

* refactor(plugins): update function signatures to return values directly instead of response structs

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

* refactor(plugins): update request/response types to use private naming conventions

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

* build: mark .wasm files as intermediate for cleanup after building .ndp

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

* refactor(plugins): consolidate PDK module path and update Go version to 1.25

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

* feat: implement Rust PDK

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

* refactor(plugins): reorganize Rust output structure to follow standard conventions

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

* refactor(plugins): update Discord Rich Presence and Library Inspector plugins to use nd-pdk for service calls and implement lifecycle management

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

* refactor(plugins): update macro names for websocket and metadata registration to improve clarity and consistency

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

* refactor(plugins): rename scheduler callback methods for consistency and clarity

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

* refactor(plugins): update export wrappers to use `//go:wasmexport` for WebAssembly compatibility

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

* docs: update plugin registration docs

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

* fix(plugins): generate host wrappers

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

* test(plugins): conditionally run goleak checks based on CI environment

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

* docs: update README to reflect changes in plugin import paths

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

* refactor(plugins): update plugin instance creation to accept context for cancellation support

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

* fix(plugins): update return types in metadata interfaces to use pointers

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

* fix(plugins): enhance type handling for Rust and XTP output in capability generation

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

* fix(plugins): update IsAuthorized method to return boolean instead of response object

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

* test(plugins): add unit tests for rustOutputType and isPrimitiveRustType functions

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

* feat(plugins): implement XTP JSONSchema validation for generated schemas

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

* fix(plugins): update response types in testMetadataAgent methods to use pointers

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

* docs: update Go and Rust plugin developer sections for clarity

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

* docs: correct example link for library inspector in README

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

* docs: clarify artwork URL generation capabilities in service descriptions

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

* docs: update README to include Rust PDK crate information for plugin developers

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

* fix: handle URL parsing errors and use atomic upsert in plugin repository

Added proper error handling for url.Parse calls in PublicURL and AbsoluteURL
functions. When parsing fails, PublicURL now falls back to AbsoluteURL, and
AbsoluteURL logs the error and returns an empty string, preventing malformed
URLs from being generated.

Replaced the non-atomic UPDATE-then-INSERT pattern in plugin repository Put
method with a single atomic INSERT ... ON CONFLICT statement. This eliminates
potential race conditions and improves consistency with the upsert pattern
already used in host_kvstore.go.

* feat: implement mock service instances for non-WASM builds using testify/mock

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

* refactor: Discord RPC struct to encapsulate WebSocket logic

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

* feat: add support for experimental WebAssembly threads

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

* feat: add PDK abstraction layer with mock support for non-WASM builds

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

* feat: add unit tests for Discord plugin and RPC functionality

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

* fix: update return types in minimalPlugin and wikimediaPlugin methods to use pointers

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

* fix: context cancellation and implement WebSocket callback timeout for improved error handling

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

* feat: conditionally include error handling in generated client code templates

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

* feat: implement ConfigService for plugin configuration management

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

* feat: enhance plugin manager to support metrics recording

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

* refactor: make MockPDK private

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

* refactor: update interface types to use 'any' in plugin repository methods

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

* refactor: rename List method to Keys for clarity in configuration management

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

* test: add ndpgen plugin tests in the pipeline and update Makefile

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

* feat: add users permission management to plugin system

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

* refactor: streamline users integration tests and enhance plugin user management

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

* refactor: remove UserID from scrobbler request structure

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

* test: add integration tests for UsersService enable gate behavior

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

* feat: implement user permissions for SubsonicAPI and scrobbler plugins

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

* fix: show proper error in the UI when enabling a plugin fails

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

* feat: add library permission management to plugin system

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

* feat: add user permission for processing scrobbles in Discord Rich Presence plugin

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

* fix: implement dynamic loading for buffered scrobbler plugins

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

* feat: add GetAdmins method to retrieve admin users from the plugin

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

* feat: update Portuguese translations for user and library permissions

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

* reorder migrations

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

* fix: remove unnecessary bulkActionButtons prop from PluginList component

* feat: add manual plugin rescan functionality and corresponding UI action

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

* feat: implement user/library and plugin management integration with cleanup on deletion

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

* feat: replace core mock services with test-specific implementations to avoid import cycles

* feat: add ID fields to Artist and Song structs and enhance track loading logic by prioritizing ID matches

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

* feat: update plugin permissions from allowedHosts to requiredHosts for better clarity and consistency

* feat: refactor plugin host permissions to use RequiredHosts directly for improved clarity

* fix: don't record metrics for plugin calls that aren't implemented at all

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

* fix: enhance connection management with improved error handling and cleanup logic

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

* feat: introduce ArtistRef struct for better artist representation and update track metadata handling

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

* feat: update user configuration handling to use user key prefix for improved clarity

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

* feat: enhance ConfigCard input fields with multiline support and vertical resizing

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

* fix: rust plugin compilation error

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

* feat: implement IsOptionPattern method for better return type handling in Rust PDK generation

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-14 19:22:48 -05:00
Deluan
9ed309ac81 feat(scanner): implement file-based target passing for large target lists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 16:08:32 -05:00
Deluan
8c80be56da fix(scanner): ensure FullScanInProgress reflects current scan request during interrupted scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 12:16:00 -05:00
Kendall Garner
c40f12e65b fix(scanner): Use repeated arg instead of comma split (#4727) 2025-11-23 22:16:10 -05:00
Deluan
67c4e24957 fix(scanner): defer artwork PreCache calls until after transaction commits
The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation.

Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images.

The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:27:25 -05:00
Deluan
c873466e5b fix(scanner): reset watcher trigger timer for debounce on notification receipt
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-19 20:24:13 -05:00
Deluan Quintão
28d5299ffc feat(scanner): implement selective folder scanning and file system watcher improvements (#4674)
* feat: Add selective folder scanning capability

Implement targeted scanning of specific library/folder pairs without
full recursion. This enables efficient rescanning of individual folders
when changes are detected, significantly reducing scan time for large
libraries.

Key changes:
- Add ScanTarget struct and ScanFolders API to Scanner interface
- Implement CLI flag --targets for specifying libraryID:folderPath pairs
- Add FolderRepository.GetByPaths() for batch folder info retrieval
- Create loadSpecificFolders() for non-recursive directory loading
- Scope GC operations to affected libraries only (with TODO for full impl)
- Add comprehensive tests for selective scanning behavior

The selective scan:
- Only processes specified folders (no subdirectory recursion)
- Maintains library isolation
- Runs full maintenance pipeline scoped to affected libraries
- Supports both full and quick scan modes

Examples:
  navidrome scan --targets "1:Music/Rock,1:Music/Jazz"
  navidrome scan --full --targets "2:Classical"

* feat(folder): replace GetByPaths with GetFolderUpdateInfo for improved folder updates retrieval

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

* test: update parseTargets test to handle folder names with spaces

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

* refactor(folder): remove unused LibraryPath struct and update GC logging message

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

* refactor(folder): enhance external scanner to support target-specific scanning

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

* refactor(scanner): simplify scanner methods

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

* feat(watcher): implement folder scanning notifications with deduplication

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

* refactor(watcher): add resolveFolderPath function for testability

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

* feat(watcher): implement path ignoring based on .ndignore patterns

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

* refactor(scanner): implement IgnoreChecker for managing .ndignore patterns

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

* refactor(ignore_checker): rename scanner to lineScanner for clarity

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

* refactor(scanner): enhance ScanTarget struct with String method for better target representation

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

* fix(scanner): validate library ID to prevent negative values

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

* refactor(scanner): simplify GC method by removing library ID parameter

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

* feat(scanner): update folder scanning to include all descendants of specified folders

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

* feat(subsonic): allow selective scan in the /startScan endpoint

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

* refactor(scanner): update CallScan to handle specific library/folder pairs

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

* refactor(scanner): streamline scanning logic by removing scanAll method

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

* test: enhance mockScanner for thread safety and improve test reliability

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

* refactor(scanner): move scanner.ScanTarget to model.ScanTarget

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

* refactor: move scanner types to model,implement MockScanner

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

* refactor(scanner): update scanner interface and implementations to use model.Scanner

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

* refactor(folder_repository): normalize target path handling by using filepath.Clean

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

* test(folder_repository): add comprehensive tests for folder retrieval and child exclusion

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

* refactor(scanner): simplify selective scan logic using slice.Filter

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

* refactor(scanner): streamline phase folder and album creation by removing unnecessary library parameter

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

* refactor(scanner): move initialization logic from phase_1 to the scanner itself

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

* refactor(tests): rename selective scan test file to scanner_selective_test.go

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

* feat(configuration): add DevSelectiveWatcher configuration option

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

* feat(watcher): enhance .ndignore handling for folder deletions and file changes

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

* docs(scanner): comments

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

* refactor(scanner): enhance walkDirTree to support target folder scanning

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

* fix(scanner, watcher): handle errors when pushing ignore patterns for folders

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

* Update scanner/phase_1_folders.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(scanner): replace parseTargets function with direct call to scanner.ParseTargets

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

* test(scanner): add tests for ScanBegin and ScanEnd functionality

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

* fix(library): update PRAGMA optimize to check table sizes without ANALYZE

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

* test(scanner): refactor tests

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

* feat(ui): add selective scan options and update translations

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

* feat(ui): add quick and full scan options for individual libraries

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

* feat(ui): add Scan buttonsto the LibraryList

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

* feat(scan): update scanning parameters from 'path' to 'target' for selective scans.

* refactor(scan): move ParseTargets function to model package

* test(scan): suppress unused return value from SetUserLibraries in tests

* feat(gc): enhance garbage collection to support selective library purging

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

* fix(scanner): prevent race condition when scanning deleted folders

When the watcher detects changes in a folder that gets deleted before
the scanner runs (due to the 10-second delay), the scanner was
prematurely removing these folders from the tracking map, preventing
them from being marked as missing.

The issue occurred because `newFolderEntry` was calling `popLastUpdate`
before verifying the folder actually exists on the filesystem.

Changes:
- Move fs.Stat check before newFolderEntry creation in loadDir to
  ensure deleted folders remain in lastUpdates for finalize() to handle
- Add early existence check in walkDirTree to skip non-existent target
  folders with a warning log
- Add unit test verifying non-existent folders aren't removed from
  lastUpdates prematurely
- Add integration test for deleted folder scenario with ScanFolders

Fixes the issue where deleting entire folders (e.g., /music/AC_DC)
wouldn't mark tracks as missing when using selective folder scanning.

* refactor(scan): streamline folder entry creation and update handling

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

* feat(scan): add '@Recycle' (QNAP) to ignored directories list

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

* fix(log): improve thread safety in logging level management

* test(scan): move unit tests for ParseTargets function

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

* review

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: deluan <deluan.quintao@mechanical-orchard.com>
2025-11-14 22:15:43 -05:00
Deluan Quintão
b2019da999 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>
2025-10-25 17:05:16 -04:00
Deluan Quintão
00c83af170 feat: Multi-library support (#4181)
* feat(database): add user_library table and library access methods

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

# Conflicts:
#	tests/mock_library_repo.go

* feat(database): enhance user retrieval with library associations

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

* feat(api): implement library management and user-library association endpoints

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

* feat(api): restrict access to library and config endpoints to admin users

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

* refactor(library): implement library management service and update API routes

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

* feat(database): add library filtering to album, folder, and media file queries

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

* refactor library service to use REST repository pattern and remove CRUD operations

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

* add total_duration column to library and update user_library table

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

* fix migration file name

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

* feat(library): add library management features including create, edit, delete, and list functionalities - WIP

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

* feat(library): enhance library validation and management with path checks and normalization - WIP

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

* feat(library): improve library path validation and error handling - WIP

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

* use utils/formatBytes

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

* simplify DeleteLibraryButton.jsx

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

* feat(library): enhance validation messages and error handling for library paths

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

* lint

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

* test(scanner): add tests for multi-library scanning and validation

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

* test(scanner): improve handling of filesystem errors and ensure warnings are returned

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

* feat(controller): add function to retrieve the most recent scan time across all libraries

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

* feat(library): add additional fields and restructure LibraryEdit component for enhanced statistics display

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

* feat(library): enhance LibraryCreate and LibraryEdit components with additional props and styling

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

* feat(mediafile): add LibraryName field and update queries to include library name

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

* feat(missingfiles): add library filter and display in MissingFilesList component

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

* feat(library): implement scanner interface for triggering library scans on create/update

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

# 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 <deluan@navidrome.org>

* rename migration file for user library table to maintain versioning order

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

* refactor: move scan triggering logic into a helper method for clarity

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

* feat(library): add library path and name fields to album and mediafile models

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

* feat(library): add/remove watchers on demand, not only when server starts

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

* refactor(scanner): streamline library handling by using state-libraries for consistency

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

* fix: track processed libraries by updating state with scan timestamps

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

* prepend libraryID for track and album PIDs

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

* feat(repository): apply library filtering in CountAll methods for albums, folders, and media files

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

* feat(user): add library selection for user creation and editing

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

* feat(library): implement library selection functionality with reducer and UI component

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

# Conflicts:
#	.github/copilot-instructions.md

# Conflicts:
#	.gitignore

* feat(library): add tests for LibrarySelector and library selection hooks

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

* test: add unit tests for file utility functions

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

* feat(library): add library ID filtering for album resources

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

* feat(library): streamline library ID filtering in repositories and update resource filtering logic

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

* fix(repository): add table name handling in filter functions for SQL queries

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

* feat(library): add refresh functionality on LibrarySelector close

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

* feat(artist): add library ID filtering for artists in repository and update resource filtering logic

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

# 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 <deluan@navidrome.org>

* feat(library): enhance LibrarySelectionField to extract library IDs from record

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

* feat(subsonic): update GetIndexes and GetArtists method to support library ID filtering

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

* 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 <deluan@navidrome.org>

* feat: enhance selectedMusicFolderIds function to handle valid music folder IDs and improve fallback logic

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

* 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 <deluan@navidrome.org>

* feat: add library access methods to User model

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

* feat: implement library access filtering for artist queries based on user permissions

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

* feat: enhance artist library filtering based on user permissions and optimize library ID retrieval

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

* 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 <deluan@navidrome.org>

* feat: refresh LibraryList on scan end

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

* fix: allow editing name of main library

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

* refactor: implement SendBroadcastMessage method for event broadcasting

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

* feat: add event broadcasting for library creation, update, and deletion

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

* feat: add useRefreshOnEvents hook for custom refresh logic on event changes

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

* feat: enhance library management with refresh event broadcasting

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

* feat: replace AddUserLibrary and RemoveUserLibrary with SetUserLibraries for better library management

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

* 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 <deluan@navidrome.org>

* 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 <deluan@navidrome.org>

* fix: correct updated_at assignment in library repository

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

* fix: improve cache buffering logic

Refactored the cache buffering logic to ensure thread safety when checking
the buffer length

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

* fix formating

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

* 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 <deluan@navidrome.org>

* feat: add multi-library support with per-library tag statistics - WIP

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

* refactor: genre and tag repositories. add comprehensive tests

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

* 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 <deluan@navidrome.org>

* refactor: rename search_test.go to searching_test.go for consistency

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

* 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 <deluan@navidrome.org>

* 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 <deluan@navidrome.org>

* refactor: cleanup

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-07-18 18:41:12 -04:00
Deluan Quintão
b63630fa6e fix(scanner) artist stats not refreshing during quick scan and after missing file deletion (#4269)
* Fix artist not being marked as touched during quick scans

When a new album is added during quick scans, artists were not being
marked as 'touched' due to media files having older modification times
than the scan completion time.

Changes:
- Add 'updated_at' to artist Put() columns in scanner to ensure
  timestamp is set when artists are processed
- Simplify RefreshStats query to check artist.updated_at directly
  instead of complex media file joins
- Artists from new albums now properly get refreshed in later phases

This fixes the issue where newly added albums would have incomplete
artist information after quick scans.

* fix(missing): refresh artist stats in background after deleting missing files

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

* fix(request): add InternalAuth to user context

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

* Add comprehensive test for artist stats update during quick scans

- Add test that verifies artist statistics are correctly updated when new files are added during incremental scans
- Test ensures both overall stats (AlbumCount, SongCount) and role-specific stats are properly refreshed
- Validates fix for artist stats not being refreshed during quick scans when new albums are added
- Uses real artist repository instead of mock to verify actual stats calculation behavior

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-26 15:50:56 -04:00
Deluan Quintão
e5e2d860ef fix(scanner): ensure full scans update the DB (#4252)
* fix: ensure full scan refreshes all artist stats

After PR #4059, full scans were not forcing a refresh of all artists.
This change ensures that during full scans, all artist stats are refreshed
instead of only those with recently updated media files.

Changes:
- Set changesDetected=true at start of full scans to ensure maintenance operations run
- Add allArtists parameter to RefreshStats() method
- Pass fullScan state to RefreshStats to control refresh scope
- Update mock repository to match new interface

Fixes #4246
Related to PR #4059

* fix: add tests for full and incremental scans

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-23 13:26:48 -04:00
Deluan
65029968ab refactor: rename chain package to run and update references
Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-14 17:19:06 -04:00
Deluan Quintão
5667f6ab75 feat(scanner): add library stats to DB (#4229)
* Combine library stats migrations

* test: verify full library stats

* Fix total_songs calculation

* Fix library stats migration

* fix(scanner): log elapsed time and number of libraries updated during scan

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

* fix(scanner): refresh library stats conditionally, only if changes were detected

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

* fix(scanner): refresh library stats conditionally, only if changes were detected

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

* fix(scanner): update queries to exclude missing entries in library stats

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-14 15:58:33 -04:00
Deluan
44834204de fix(scanner): improve folderEntry methods and hashing logic for better change detection
Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-14 12:35:28 -04:00
Deluan Quintão
0d74d36cec feat(scanner): add folder hash for smarter quick scan change detection (#4220)
* Simplify folder hash migration

* fix hashing lint

* refactor

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

* Update scanner/folder_entry.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-12 13:17:34 -04:00
Kendall Garner
b79e84a535 fix(scanner): update prometheus at the end of the scan (#4163)
* fix(scannner): use prometheus instance over noop if configured properly

* Real Fix: move `WriteAfterScanMetrics` outside gofunc

* refactor: remove unused artwork.CacheWarmer param from CallScan function

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-06-02 20:13:54 -04:00
Deluan
3350e6c115 fix(ui): elapsed time for scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 23:28:02 -04:00
Deluan
1e4c759d93 test: fix flaky scanner tests by setting maximum open connections to 1
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 15:39:44 -04:00
Copilot
992c78376c feat(scanner): add Scanner.PurgeMissing configuration option (#4107)
* Initial plan for issue

* Add Scanner.PurgeMissing configuration option

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Remove GC call from phaseMissingTracks.purgeMissing method

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Address PR comments for Scanner.PurgeMissing feature

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Address PR comments and add DeleteAllMissing method

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* refactor(scanner): simplify purgeMissing logic and improve error handling

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

* fix configuration test

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-05-22 20:50:15 -04:00
Deluan Quintão
6880cffd16 feat(ui): add scan progress and error reporting to UI (#4094)
* feat(scanner): add LastScanError tracking to scanner status

- Introduced LastScanErrorKey constant for error tracking.
- Updated StatusInfo struct to include LastError field.
- Modified scanner logic to store and retrieve last scan error.
- Enhanced ScanStatus response to include error information.
- Updated UI components to display last scan error when applicable.
- Added tests to verify last scan error functionality.

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

* feat(scanner): enhance scan status with type and elapsed time tracking

- Added LastScanTypeKey and LastScanStartTimeKey constants for tracking scan type and start time.
- Updated StatusInfo struct to include ScanType and ElapsedTime fields.
- Implemented getScanInfo method to retrieve scan type, elapsed time, and last error.
- Modified scanner logic to store scan type and start time during scans.
- Enhanced ScanStatus response and UI components to display scan type and elapsed time.
- Added formatShortDuration utility for better elapsed time representation.
- Updated activity reducer to handle new scan status fields.

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

* refactor(tests): consolidate controller status tests into a single file

- Removed the old controller_status_test.go file.
- Merged relevant tests into the new controller_test.go file for better organization and maintainability.
- Ensured all existing test cases for controller status are preserved and functional.

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

* Fix formatting issues

* refactor(scanner): update getScanInfo method documentation

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 09:30:23 -04:00