Compare commits

..

32 Commits

Author SHA1 Message Date
Deluan
fffbfc5f08 feat(db): statically compile spellfix1 SQLite extension
Vendor the spellfix1 extension source and compile it directly into the
binary via cgo, avoiding runtime shared library dependencies across
platforms. The extension is registered via sqlite3_auto_extension so
every new SQLite connection has spellfix1 available.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 15:51:10 -05:00
Deluan Quintão
b59eb32961 feat(subsonic): sort search3 results by relevance (#5086)
* fix(subsonic): optimize search3 for high-cardinality FTS queries

Use a two-phase query strategy for FTS5 searches to avoid the
performance penalty of expensive LEFT JOINs (annotation, bookmark,
library) on high-cardinality results like "the".

Phase 1 runs a lightweight query (main table + FTS index only) to get
sorted, paginated rowids. Phase 2 hydrates only those few rowids with
the full JOINs, making them nearly free.

For queries with complex ORDER BY expressions that reference joined
tables (e.g. artist search sorted by play count), the optimization is
skipped and the original single-query approach is used.

* fix(search): update order by clauses to include 'rank' for FTS queries

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

* fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries

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

* fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling

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

* fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification

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

* fix(search): refine FTS query handling and improve comments for clarity

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

* fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic.

Increase e2e coverage for search3

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

* refactor: enhance FTS column definitions and relevance weights

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

* fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling

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

* fix(search): allow single-character queries in search strategies and update related tests

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

* fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests

FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which
produced LIMIT 0 when Max was zero. This diverged from applyOptions
where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add
LIMIT/OFFSET when the value is positive. Also moved legacy backend
integration tests from sql_search_fts_test.go to sql_search_like_test.go
and added regression tests for the Max=0 behavior on both backends.

* refactor: simplify callSearch function by removing variadic options and directly using QueryOptions

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

* fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 08:51:54 -05:00
Valeri Sokolov
23bf256a66 feat: make album and artist annotations available to smart playlists (#4927)
* feat(criteria): make album ratings available to smart playlist queries

Expose an "albumrating" field mapping to album annotations.

Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net>

* fix(criteria): use query parameters

Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net>

* feat: add album and artist annotation fields to smart playlists

Extend smart playlists to filter songs by album or artist annotations
(rating, loved, play count, last played, date loved, date rated). This
adds 12 new fields (6 album, 6 artist) with conditional JOINs that are
only added when the criteria or sort references them, avoiding
unnecessary query overhead. The album table JOIN is also removed since
media_file.album_id can be used directly.

---------

Signed-off-by: Valeri Sokolov <ulfurinn@ulfurinn.net>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-02-22 22:05:59 -05:00
Deluan
d02bf9a53d test(e2e): add MusicBrainz ID tests for song and album searches
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-22 00:32:14 -05:00
Deluan
ec75808153 fix(subsonic): handle empty quoted phrases in FTS5 query and search expression
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 22:00:00 -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
76c01566a9 test(ui): change datagrid from table to div to fix warning
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 18:57:12 -05:00
Deluan
1cf3fd9161 fix(scanner): prevent ScanOnStartup when scanner is disabled
Gate the ScanOnStartup config on Scanner.Enabled so that setting
Scanner.Enabled=false prevents automatic startup scans. Other automatic
scan triggers (interrupted scan resume, PID change, post-migration) are
preserved regardless of the Enabled flag to maintain data integrity.
2026-02-21 18:51:16 -05:00
Deluan Quintão
54de0dbc52 feat(server): implement FTS5-based full-text search (#5079)
* build: add sqlite_fts5 build tag to enable FTS5 support

* feat: add SearchBackend config option (default: fts)

* feat: add buildFTS5Query for safe FTS5 query preprocessing

* feat: add FTS5 search backend with config toggle, refactor legacy search

- Add searchExprFunc type and getSearchExpr() for backend selection
- Rename fullTextExpr to legacySearchExpr
- Add ftsSearchExpr using FTS5 MATCH subquery
- Update fullTextFilter in sql_restful.go to use configured backend

* feat: add FTS5 migration with virtual tables, triggers, and search_participants

Creates FTS5 virtual tables for media_file, album, and artist with
unicode61 tokenizer and diacritic folding. Adds search_participants
column, populates from JSON, and sets up INSERT/UPDATE/DELETE triggers.

* feat: populate search_participants in PostMapArgs for FTS5 indexing

* test: add FTS5 search integration tests

* fix: exclude FTS5 virtual tables from e2e DB restore

The restoreDB function iterates all tables in sqlite_master and
runs DELETE + INSERT to reset state. FTS5 contentless virtual tables
cannot be directly deleted from. Since triggers handle FTS5 sync
automatically, simply skip tables matching *_fts and *_fts_* patterns.

* build: add compile-time guard for sqlite_fts5 build tag

Same pattern as netgo: compilation fails with a clear error if
the sqlite_fts5 build tag is missing.

* build: add sqlite_fts5 tag to reflex dev server config

* build: extract GO_BUILD_TAGS variable in Makefile to avoid duplication

* fix: strip leading * from FTS5 queries to prevent "unknown special query" error

* feat: auto-append prefix wildcard to FTS5 search tokens for broader matching

Every plain search token now gets a trailing * appended (e.g., "love" becomes
"love*"), so searching for "love" also matches "lovelace", "lovely", etc.
Quoted phrases are preserved as exact matches without wildcards. Results are
ordered alphabetically by name/title, so shorter exact matches naturally
appear first.

* fix: clarify comments about FTS5 operator neutralization

The comments said "strip" but the code lowercases operators to
neutralize them (FTS5 operators are case-sensitive). Updated comments
to accurately describe the behavior.

* fix: use fmt.Sprintf for FTS5 phrase placeholders

The previous encoding used rune('0'+index) which silently breaks with
10+ quoted phrases. Use fmt.Sprintf for arbitrary index support.

* fix: validate and normalize SearchBackend config option

Normalize the value to lowercase and fall back to "fts" with a log
warning for unrecognized values. This prevents silent misconfiguration
from typos like "FTS", "Legacy", or "fts5".

* refactor: improve documentation for build tags and FTS5 requirements

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

* refactor: convert FTS5 query and search backend normalization tests to DescribeTable format

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

* fix: add sqlite_fts5 build tag to golangci configuration

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

* feat: add UISearchDebounceMs configuration option and update related components

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

* fix: fall back to legacy search when SearchFullString is enabled

FTS5 is token-based and cannot match substrings within words, so
getSearchExpr now returns legacySearchExpr when SearchFullString
is true, regardless of SearchBackend setting.

* fix: add sqlite_fts5 build tag to CI pipeline and Dockerfile

* fix: add WHEN clauses to FTS5 AFTER UPDATE triggers

Added WHEN clauses to the media_file_fts_au, album_fts_au, and
artist_fts_au triggers so they only fire when FTS-indexed columns
actually change. Previously, every row update (e.g., play count, rating,
starred status) triggered an unnecessary delete+insert cycle in the FTS
shadow tables. The WHEN clauses use IS NOT for NULL-safe comparison of
each indexed column, avoiding FTS index churn for non-indexed updates.

* feat: add SearchBackend configuration option to data and insights components

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

* fix: enhance input sanitization for FTS5 by stripping additional punctuation and special characters

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

* feat: add search_normalized column for punctuated name search (R.E.M., AC/DC)

Add index-time normalization and query-time single-letter collapsing to
fix FTS5 search for punctuated names. A new search_normalized column
stores concatenated forms of punctuated words (e.g., "R.E.M." → "REM",
"AC/DC" → "ACDC") and is indexed in FTS5 tables. At query time, runs of
consecutive single letters (from dot-stripping) are collapsed into OR
expressions like ("R E M" OR REM*) to match both the original tokens and
the normalized form. This enables searching by "R.E.M.", "REM", "AC/DC",
"ACDC", "A-ha", or "Aha" and finding the correct results.

* refactor: simplify isSingleUnicodeLetter to avoid []rune allocation

Use utf8.DecodeRuneInString to check for a single Unicode letter
instead of converting the entire string to a []rune slice.

* feat: define ftsSearchColumns for flexible FTS5 search column inclusion

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

* feat: update collapseSingleLetterRuns to return quoted phrases for abbreviations

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

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

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

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

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

* refactor: punctuated word handling to improve processing of artist/album names

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

* feat: add CJK support for search queries with LIKE filters

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

* feat: enhance FTS5 search by adding album version support and CJK handling

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

* refactor: search configuration to use structured options

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

* feat: enhance search functionality to support punctuation-only queries and update related tests

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 17:52:42 -05:00
Deluan
6f5f58ae9d chore(deps): update go-taglib to v0.0.0-20260221220301-2fab4903f48e
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 17:04:59 -05:00
Deluan Quintão
821f22a86f feat(scanner): upgrade TagLib to 2.2, with MKA/Matroska support (#5071)
* chore(deps): update go-taglib fork with MKA/Matroska support

Bump deluan/go-taglib to cf75207bfff8, which upgrades the underlying
taglib to v2.2 and adds Matroska container format detection and
metadata handling (MKA audio files).

* chore(deps): update cross-taglib version to 2.2.0-1

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

* chore(make): rename run-docker target to docker-run for consistency

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

* chore(go-taglib): update version to 2.2 WASM and add debug logging

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

* chore(deps): update go-taglib to v0.0.0-20260220032326 for MKA fixes

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 16:52:48 -05:00
Boris Rorsvort
74aa4d6fa5 fix(ui): Search focus after clear (#4932)
* wip

* refactor implem

* fixes
2026-02-21 14:39:38 -05:00
dependabot[bot]
dc4607c657 chore(deps): bump ajv from 6.12.6 to 6.14.0 in /ui (#5080)
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 12:44:32 -05:00
Deluan
ddab0da207 docs: update commit message format in CONTRIBUTING.md
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-20 11:00:34 -05:00
Deluan Quintão
08a71320ea fix(ui): make toggle switches visible in Gruvbox Dark theme (#5063) (#5064)
The secondary color (#3c3836) matches the panel/table cell background,
making checked MuiSwitch thumbs invisible. Add MuiSwitch override using
Gruvbox cyan (#458588), consistent with existing interactive elements.
2026-02-18 15:38:20 -05:00
Raphael Catolino
44a5482493 fix(ui): activity Indicator switching constantly between online/offline (#5054)
When using HTTP2, setting the writeTimeout too low causes the channel to
close before the keepAlive event has a chance of beeing sent.

Signed-off-by: rca <raphael.catolino@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-17 14:47:20 -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
b774133cd1 chore(deps): update go-sqlite3 to v1.14.34 and pocketbase/dbx to v1.12.0
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 08:35:02 -05:00
Alanna
a20d56c137 fix(ui): prevent "Play Next" restarting play at top of queue (#5049)
Set playIndex when rebuilding the queue in reducePlayNext so the music
player library knows which track is currently playing. Without this, the
library's loadNewAudioLists defaults playIndex to 0, causing playback to
restart from the top of the queue on rapid "Play Next" actions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:34:24 -05:00
Deluan
b64d8ad334 fix(server): return 404 instead of 500 for non-existent playlists
The native API endpoints GET /playlist/{id}/tracks and
GET /playlist/{id}/tracks/{id} were panicking with a nil pointer
dereference (resulting in a 500) when the playlist did not exist.
This happened because Tracks() returns nil for missing playlists,
and the nil repository was passed directly to the rest handler.
Extracted a shared playlistTracksHandler that checks for nil and
returns 404 early. Added tests covering both the error and happy paths.
2026-02-15 22:39:27 -05:00
Paul Becker
f00af7f983 feat(ui): add Dracula theme (#5023)
Signed-off-by: Paul Becker <p@becker.kiwi>
2026-02-12 16:42:34 -05:00
Deluan Quintão
875ffc2b78 fix(ui): update Danish, Portuguese (BR) translations from POEditor (#5039)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-02-12 16:38:57 -05:00
ChekeredList71
885334c819 fix(ui): update Hungarian translation (#5041)
* new strings added

* "empty" solved

---------

Co-authored-by: ChekeredList71 <asd@asd.com>
2026-02-12 16:36:05 -05:00
Deluan
ff86b9f2b9 ci: add GitHub Actions workflow for pushing translations to POEditor 2026-02-12 16:32:58 -05:00
Xabi
13d3d510f5 fix(ui): update Basque localisation (#5038)
* Update Basque localisation

Added missing strings and a couple of improvements.

* Update resources/i18n/eu.json

typo

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-12 15:52:37 -05:00
fxj368
656009e5f8 fix(i18n) update Chinese Simplified translation (#5025)
* Update Chinese Simplified translation

* fix some structural issue and an incorrect translation
2026-02-12 15:49:20 -05:00
Deluan
06b3a1f33e fix(insights): update HasCustomPID logic to use default constants
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-12 14:33:25 -05:00
Kendall Garner
0f4e8376cb feat(ui): add download config toml link, disable copy when clipboard not available (#5035) 2026-02-12 10:54:04 -05:00
Deluan
199cde4109 fix: upgrade go-taglib to latest version
Updated the go-taglib dependency to pick up the latest bug fixes from
the forked repository. This resolves an issue reported in #5037.
2026-02-12 10:12:04 -05:00
Deluan
897de02a84 docs: documents how subsonic e2e tests are structured 2026-02-11 22:49:41 -05:00
Deluan
7ee56fe3bf chore: update golangci-lint version to v2.9.0 in Makefile
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-11 08:31:51 -05:00
140 changed files with 11070 additions and 1709 deletions

View File

@@ -14,7 +14,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \

View File

@@ -8,7 +8,7 @@
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
"CROSS_TAGLIB_VERSION": "2.2.0-1"
}
},
"workspaceMount": "",

View File

@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-2"
CROSS_TAGLIB_VERSION: "2.2.0-1"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
@@ -117,7 +117,7 @@ jobs:
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race ./... -v
go test -shuffle=on -tags netgo,sqlite_fts5,sqlite_spellfix -race ./... -v
- name: Test ndpgen
run: |

138
.github/workflows/push-translations.sh vendored Executable file
View File

@@ -0,0 +1,138 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Normalize JSON for deterministic comparison:
# remove empty/null attributes, sort keys alphabetically
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Get list of all languages configured in the POEditor project
get_language_list() {
curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}"
}
# Extract language name from the language list JSON given a language code
get_language_name() {
lang_code="$1"
lang_list="$2"
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
}
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
get_lang_code() {
filepath="$1"
filename=$(basename "$filepath")
echo "${filename%.*}"
}
# Export the current translation for a language from POEditor (v2 API)
export_language() {
lang_code="$1"
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
-d type="key_value_json")
url=$(echo "$response" | jq -r '.result.url')
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Failed to export $lang_code: $response" >&2
return 1
fi
echo "$url"
}
# Flatten nested JSON to POEditor languages/update format.
# POEditor uses term + context pairs, where:
# term = the leaf key name
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
flatten_to_poeditor() {
jq -c '[paths(scalars) as $p |
{
"term": ($p | last | tostring),
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
"translation": {"content": getpath($p)}
}
]' "$1"
}
# Update translations for a language in POEditor via languages/update API
update_language() {
lang_code="$1"
file="$2"
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
--data-urlencode data@/tmp/poeditor_data.json)
rm -f /tmp/poeditor_data.json
status=$(echo "$response" | jq -r '.response.status')
if [ "$status" != "success" ]; then
echo "Failed to update $lang_code: $response" >&2
return 1
fi
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
added=$(echo "$response" | jq -r '.result.translations.added')
updated=$(echo "$response" | jq -r '.result.translations.updated')
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
}
# --- Main ---
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2] ..."
echo "No files specified. Nothing to do."
exit 0
fi
lang_list=$(get_language_list)
upload_count=0
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Warning: File not found: $file, skipping"
continue
fi
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
if [ -z "$lang_name" ]; then
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
continue
fi
echo "Processing $lang_name ($lang_code)..."
# Export current state from POEditor
url=$(export_language "$lang_code")
curl -sSL "$url" -o poeditor_export.json
# Normalize both files for comparison
process_json "$file" > local_normalized.json
process_json poeditor_export.json > remote_normalized.json
# Compare normalized versions
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
echo " No differences, skipping"
else
echo " Differences found, updating POEditor..."
update_language "$lang_code" "$file"
upload_count=$((upload_count + 1))
fi
rm -f poeditor_export.json local_normalized.json remote_normalized.json
done
echo ""
echo "Done. Updated $upload_count translation(s) in POEditor."

32
.github/workflows/push-translations.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: POEditor export
on:
push:
branches:
- master
paths:
- 'resources/i18n/*.json'
jobs:
push-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Detect changed translation files
id: changed
run: |
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "Changed translation files: $CHANGED_FILES"
- name: Push translations to POEditor
if: ${{ steps.changed.outputs.files != '' }}
env:
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
run: |
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}

View File

@@ -2,6 +2,8 @@ version: "2"
run:
build-tags:
- netgo
- sqlite_fts5
- sqlite_spellfix
linters:
enable:
- asasalint

View File

@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
### Commit Conventions
Each commit message must adhere to the following format:
```
<type>(scope): <description> - <issue number>
<type>(scope): <description>
[optional body]
```

View File

@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-2
ARG CROSS_TAGLIB_VERSION=2.2.0-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects
@@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
export EXT=".exe"
fi
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
go build -tags=netgo,sqlite_fts5,sqlite_spellfix -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .

View File

@@ -1,5 +1,6 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GO_BUILD_TAGS=netgo,sqlite_fts5,sqlite_spellfix
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
@@ -19,8 +20,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.8.0
CROSS_TAGLIB_VERSION ?= 2.2.0-1
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -46,12 +47,12 @@ stop: ##@Development Stop development servers (UI and backend)
.PHONY: stop
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=netgo -notify ./...
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
go test -tags netgo $(PKG)
go test -tags $(GO_BUILD_TAGS) $(PKG)
.PHONY: test
test-ndpgen: ##@Development Run tests for ndpgen plugin
@@ -62,7 +63,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags netgo -race -shuffle=on $(PKG)
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
@@ -108,7 +109,7 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags=netgo ./...
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
@@ -144,14 +145,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: build
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
@@ -201,8 +202,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
@@ -213,7 +214,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
.PHONY: run-docker
.PHONY: docker-run
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi

View File

@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
type authResponse struct {
JWT string `json:"jwt"`
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse

View File

@@ -20,6 +20,7 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -43,7 +44,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
return "go-taglib (TagLib 2.1.1 WASM)"
return "2.2 WASM"
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
@@ -279,4 +280,7 @@ func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}

View File

@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
return
}

View File

@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
}
type listenBrainzRequest struct {
ApiKey string
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}

View File

@@ -196,7 +196,8 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)

View File

@@ -8,7 +8,7 @@ import (
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
pls := playlists.NewPlaylists(ds)
// Parse targets from command line or file
var scanTargets []model.ScanTarget

View File

@@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@@ -61,7 +62,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@@ -72,12 +73,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
return router
}
@@ -98,11 +99,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -165,8 +166,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@@ -182,8 +183,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}

View File

@@ -1,4 +0,0 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.

6
conf/buildtags/doc.go Normal file
View File

@@ -0,0 +1,6 @@
// Package buildtags provides compile-time enforcement of required build tags.
//
// Each file in this package is guarded by a build constraint and exports a variable
// that main.go references. If a required tag is missing during compilation, the build
// fails with an "undefined" error, directing the developer to use `make build`.
package buildtags

View File

@@ -2,10 +2,6 @@
package buildtags
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
var NETGO = true

View File

@@ -0,0 +1,8 @@
//go:build sqlite_fts5
package buildtags
// FTS5 is required for full-text search. Without this tag, the SQLite driver
// won't include FTS5 support, causing runtime failures on migrations and search queries.
var SQLITE_FTS5 = true

View File

@@ -0,0 +1,8 @@
//go:build sqlite_spellfix
package buildtags
// SPELLFIX is required for the spellfix1 virtual table, used for fuzzy/approximate
// string matching. Without this tag, the SQLite driver won't include spellfix1 support.
var SQLITE_SPELLFIX = true

View File

@@ -58,7 +58,7 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
Search searchOptions `json:",omitzero"`
SimilarSongsMatchThreshold int
RecentlyAddedByModTime bool
PreferSortTags bool
@@ -82,6 +82,7 @@ type configOptions struct {
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
@@ -172,8 +173,8 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
ApiKey string //nolint:gosec
Secret string //nolint:gosec
Language string
ScrobbleFirstArtistOnly bool
@@ -183,7 +184,7 @@ type lastfmOptions struct {
type spotifyOptions struct {
ID string
Secret string
Secret string //nolint:gosec
}
type deezerOptions struct {
@@ -208,7 +209,7 @@ type httpHeaderOptions struct {
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
Password string //nolint:gosec
}
type AudioDeviceDefinition []string
@@ -251,6 +252,11 @@ type extAuthOptions struct {
UserHeader string
}
type searchOptions struct {
Backend string
FullString bool
}
var (
Server = &configOptions{}
hooks []func()
@@ -344,6 +350,8 @@ func Load(noConfigDump bool) {
os.Exit(1)
}
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
@@ -392,6 +400,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("SearchFullString", "Search.FullString")
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
@@ -539,6 +548,17 @@ func validateSchedule(schedule, field string) (string, error) {
return schedule, err
}
func normalizeSearchBackend(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "fts", "legacy":
return v
default:
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
return "fts"
}
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
func AddHook(hook func()) {
hooks = append(hooks, hook)
@@ -585,7 +605,8 @@ func setViperDefaults() {
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("search.fullstring", false)
viper.SetDefault("search.backend", "fts")
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
@@ -604,6 +625,7 @@ func setViperDefaults() {
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
@@ -748,7 +770,7 @@ func getConfigFile(cfgFile string) string {
}
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
if _, err := os.Stat(cfgFile); err == nil {
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
return cfgFile
}
}

View File

@@ -52,6 +52,20 @@ var _ = Describe("Configuration", func() {
})
})
DescribeTable("NormalizeSearchBackend",
func(input, expected string) {
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
},
Entry("accepts 'fts'", "fts", "fts"),
Entry("accepts 'legacy'", "legacy", "legacy"),
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
Entry("trims whitespace", " fts ", "fts"),
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
Entry("falls back to 'fts' for empty string", "", "fts"),
)
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

View File

@@ -7,3 +7,5 @@ func ResetConf() {
var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var NormalizeSearchBackend = normalizeSearchBackend

View File

@@ -66,11 +66,12 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultHttpClientTimeOut = 10 * time.Second

View File

@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}

View File

@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
@@ -208,7 +208,8 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.SearchFullString = conf.Server.Search.FullString
data.Config.SearchBackend = conf.Server.Search.Backend
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
@@ -220,7 +221,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data

View File

@@ -68,6 +68,7 @@ type Data struct {
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
SearchBackend string `json:"searchBackend,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
PreferSortTags bool `json:"preferSortTags,omitempty"`
BackupSchedule string `json:"backupSchedule,omitempty"`

119
core/playlists/import.go Normal file
View File

@@ -0,0 +1,119 @@
package playlists
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"golang.org/x/text/unicode/norm"
)
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, filename, folder)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
return nil, err
}
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, nil, reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, reader)
default:
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}
if err == nil {
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
}
return s.ds.Playlist(ctx).Put(newPls)
}

View File

@@ -1,4 +1,4 @@
package core_test
package playlists_test
import (
"context"
@@ -9,7 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -19,18 +19,18 @@ import (
"golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists", func() {
var _ = Describe("Playlists - Import", func() {
var ds *tests.MockDataStore
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = mockedPlaylistRepo{}
mockPlsRepo = tests.CreateMockPlaylistRepo()
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedPlaylist: &mockPlsRepo,
MockedPlaylist: mockPlsRepo,
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
@@ -39,7 +39,7 @@ var _ = Describe("Playlists", func() {
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
@@ -61,7 +61,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@@ -99,7 +99,7 @@ var _ = Describe("Playlists", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
@@ -149,7 +149,7 @@ var _ = Describe("Playlists", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
@@ -163,7 +163,7 @@ var _ = Describe("Playlists", func() {
Path: storedPath,
Sync: true,
}
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
// Import using the filesystem's normalization form
plsFolder := &model.Folder{
@@ -209,7 +209,7 @@ var _ = Describe("Playlists", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
@@ -365,7 +365,7 @@ var _ = Describe("Playlists", func() {
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
@@ -408,7 +408,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -439,7 +439,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
@@ -460,7 +460,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
repo.data = []string{
"album1/test1.mp3",
"album2/test2.mp3",
@@ -570,7 +570,7 @@ var _ = Describe("Playlists", func() {
})
Describe("InPlaylistsPath", func() {
Describe("InPath", func() {
var folder model.Folder
BeforeEach(func() {
@@ -584,27 +584,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
Expect(playlists.InPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
Expect(playlists.InPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -612,7 +612,7 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
Expect(playlists.InPath(folder2)).To(BeTrue())
})
})
})
@@ -693,23 +693,3 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
return mfs, nil
}
type mockedPlaylistRepo struct {
last *model.Playlist
data map[string]*model.Playlist // keyed by path
model.PlaylistRepository
}
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if r.data != nil {
if pls, ok := r.data[path]; ok {
return pls, nil
}
}
return nil, model.ErrNotFound
}
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View File

@@ -1,183 +1,28 @@
package core
package playlists
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/bmatcuk/doublestar/v4"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
type Playlists interface {
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPlaylistsPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
}
return false
}
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, filename, folder)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
return nil, err
}
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, nil, reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, reader)
default:
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
info, err := os.Stat(playlistPath)
if err != nil {
return nil, err
}
var extension = filepath.Ext(playlistFile)
var name = playlistFile[0 : len(playlistFile)-len(extension)]
pls := &model.Playlist{
Name: name,
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
Public: false,
Path: playlistPath,
Sync: true,
UpdatedAt: info.ModTime(),
}
return pls, nil
}
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
line = 1
for _, b := range data[:offset] {
if b == '\n' {
line++
column = 1
} else {
column++
}
}
return
}
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
nsp := &nspFile{}
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
reader = jsoncommentstrip.NewReader(reader)
input, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("reading SmartPlaylist: %w", err)
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
pls.Name = nsp.Name
}
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return err
}
var mfs model.MediaFiles
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
@@ -202,7 +47,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
@@ -258,7 +103,9 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
existing[key] = idx
}
// Find media files in the order of the resolved paths, to keep playlist order
// Find media files in the order of the resolved paths, to keep playlist order.
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
for _, path := range resolvedPaths {
key := strings.ToLower(norm.NFC.String(path))
idx, ok := existing[key]
@@ -398,15 +245,10 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return nil, err
}
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
results := make([]string, 0, len(lines))
for idx, line := range lines {
resolution := resolver.resolvePath(line, folder)
resolution := r.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
@@ -425,123 +267,3 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}
if err == nil {
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
}
return s.ds.Playlist(ctx).Put(newPls)
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
needsInfoUpdate := name != nil || comment != nil || public != nil
needsTrackRefresh := len(idxToRemove) > 0
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)
tracks := repo.Tracks(playlistID, true)
if tracks == nil {
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
}
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true, false)
pls.RemoveTracks(idxToRemove)
pls.AddMediaFilesByID(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = tracks.Add(idsToAdd)
if err != nil {
return err
}
}
if needsInfoUpdate {
pls, err = repo.Get(playlistID)
}
}
if err != nil {
return err
}
if !needsTrackRefresh && !needsInfoUpdate {
return nil
}
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
// Special case: The playlist is now empty
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
if err = tracks.DeleteAll(); err != nil {
return err
}
}
return repo.Put(pls)
})
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
}
return json.Unmarshal(data, &i.Criteria)
}

View File

@@ -1,4 +1,4 @@
package core
package playlists
import (
"context"
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Context("basic", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
Context("cross-library", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)

103
core/playlists/parse_nsp.go Normal file
View File

@@ -0,0 +1,103 @@
package playlists
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
)
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
info, err := os.Stat(playlistPath)
if err != nil {
return nil, err
}
var extension = filepath.Ext(playlistFile)
var name = playlistFile[0 : len(playlistFile)-len(extension)]
pls := &model.Playlist{
Name: name,
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
Public: false,
Path: playlistPath,
Sync: true,
UpdatedAt: info.ModTime(),
}
return pls, nil
}
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
line = 1
for _, b := range data[:offset] {
if b == '\n' {
line++
column = 1
} else {
column++
}
}
return
}
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
nsp := &nspFile{}
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
reader = jsoncommentstrip.NewReader(reader)
input, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("reading SmartPlaylist: %w", err)
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
pls.Name = nsp.Name
}
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
}
return json.Unmarshal(data, &i.Criteria)
}

View File

@@ -0,0 +1,213 @@
package playlists
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseNSP", func() {
var s *playlists
ctx := context.Background()
BeforeEach(func() {
s = &playlists{}
})
It("parses a well-formed NSP with all fields", func() {
nsp := `{
"name": "My Smart Playlist",
"comment": "A test playlist",
"public": true,
"all": [{"is": {"loved": true}}],
"sort": "title",
"order": "asc",
"limit": 50
}`
pls := &model.Playlist{Name: "default-name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Smart Playlist"))
Expect(pls.Comment).To(Equal("A test playlist"))
Expect(pls.Public).To(BeTrue())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("title"))
Expect(pls.Rules.Order).To(Equal("asc"))
Expect(pls.Rules.Limit).To(Equal(50))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
It("keeps existing name when NSP has no name field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original Name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Original Name"))
})
It("keeps existing comment when NSP has no comment field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Comment: "Original Comment"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Comment).To(Equal("Original Comment"))
})
It("strips JSON comments before parsing", func() {
nsp := `{
// Line comment
"name": "Commented Playlist",
/* Block comment */
"all": [{"is": {"loved": true}}]
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Commented Playlist"))
})
It("uses server default when public field is absent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeTrue())
})
It("honors explicit public: false over server default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeFalse())
})
It("returns a syntax error with line and column info", func() {
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
})
It("returns a parsing error for completely invalid JSON", func() {
nsp := `not json at all`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
})
It("gracefully handles non-string name field", func() {
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
// Type assertion in UnmarshalJSON fails silently; name stays as original
Expect(pls.Name).To(Equal("Original"))
})
It("parses criteria with multiple rules", func() {
nsp := `{
"all": [
{"is": {"loved": true}},
{"contains": {"title": "rock"}}
],
"sort": "lastPlayed",
"order": "desc",
"limit": 100
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
})
})
var _ = Describe("getPositionFromOffset", func() {
It("returns correct position on first line", func() {
data := []byte("hello world")
line, col := getPositionFromOffset(data, 5)
Expect(line).To(Equal(1))
Expect(col).To(Equal(5))
})
It("returns correct position after newlines", func() {
data := []byte("line1\nline2\nline3")
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
line, col := getPositionFromOffset(data, 8)
Expect(line).To(Equal(2))
Expect(col).To(Equal(3))
})
It("returns correct position at start of new line", func() {
data := []byte("line1\nline2")
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(2))
Expect(col).To(Equal(1))
})
It("handles multiple newlines", func() {
data := []byte("a\nb\nc\nd")
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(4))
Expect(col).To(Equal(1))
})
})
var _ = Describe("newSyncedPlaylist", func() {
var s *playlists
BeforeEach(func() {
s = &playlists{}
})
It("creates a synced playlist with correct attributes", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("test"))
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
Expect(pls.Public).To(BeFalse())
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
Expect(pls.Sync).To(BeTrue())
Expect(pls.UpdatedAt).ToNot(BeZero())
})
It("strips extension from filename to derive name", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Favorites"))
})
It("returns error for non-existent file", func() {
tmpDir := GinkgoT().TempDir()
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
Expect(err).To(HaveOccurred())
})
})

265
core/playlists/playlists.go Normal file
View File

@@ -0,0 +1,265 @@
package playlists
import (
"context"
"io"
"path/filepath"
"strconv"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type Playlists interface {
// Reads
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
Get(ctx context.Context, id string) (*model.Playlist, error)
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
// Mutations
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
// Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
// REST adapters (follows Share/Library pattern)
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
}
return false
}
// --- Read operations ---
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetAll(options...)
}
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).Get(id)
}
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
}
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
}
// --- Mutation operations ---
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
usr, _ := request.UserFrom(ctx)
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if pls.IsSmartPlaylist() {
return model.ErrNotAuthorized
}
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return model.ErrNotAuthorized
}
} else {
pls = &model.Playlist{Name: name}
pls.OwnerID = usr.ID
}
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
return err
})
return playlistId, err
}
func (s *playlists) Delete(ctx context.Context, id string) error {
if _, err := s.checkWritable(ctx, id); err != nil {
return err
}
return s.ds.Playlist(ctx).Delete(id)
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
var pls *model.Playlist
var err error
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
if hasTrackChanges {
pls, err = s.checkTracksEditable(ctx, playlistID)
} else {
pls, err = s.checkWritable(ctx, playlistID)
}
if err != nil {
return err
}
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Playlist(ctx)
if len(idxToRemove) > 0 {
tracksRepo := repo.Tracks(playlistID, false)
// Convert 0-based indices to 1-based position IDs and delete them directly,
// avoiding the need to load all tracks into memory.
positions := make([]string, len(idxToRemove))
for i, idx := range idxToRemove {
positions[i] = strconv.Itoa(idx + 1)
}
if err := tracksRepo.Delete(positions...); err != nil {
return err
}
if len(idsToAdd) > 0 {
if _, err := tracksRepo.Add(idsToAdd); err != nil {
return err
}
}
return s.updateMetadata(ctx, tx, pls, name, comment, public)
}
if len(idsToAdd) > 0 {
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
return err
}
}
if name == nil && comment == nil && public == nil {
return nil
}
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
return s.updateMetadata(ctx, tx, pls, name, comment, public)
})
}
// --- Permission helpers ---
// checkWritable fetches the playlist and verifies the current user can modify it.
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).Get(id)
if err != nil {
return nil, err
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return nil, err
}
if pls.IsSmartPlaylist() {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// updateMetadata applies optional metadata changes to a playlist and persists it.
// Accepts a DataStore parameter so it can be used inside transactions.
// The caller is responsible for permission checks.
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
return ds.Playlist(ctx).Put(pls)
}
// --- Track management operations ---
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
}
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
}
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
}
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
})
}
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View File

@@ -0,0 +1,17 @@
package playlists_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlaylists(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playlists Suite")
}

View File

@@ -0,0 +1,297 @@
package playlists_test
import (
"context"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("Delete", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to delete their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("allows admin to delete any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner, non-admin from deleting", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockPlsRepo.Deleted).To(BeEmpty())
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "nonexistent")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("Create", func() {
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
ps = playlists.NewPlaylists(ds)
})
It("creates a new playlist with owner set from context", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
})
It("replaces tracks on existing playlist when owner matches", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-1"))
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
})
It("allows admin to replace tracks on any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-2"))
})
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when existing playlistId not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies replacing tracks on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("Update", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies adding tracks to a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies removing tracks from a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("AddTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to add tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
})
It("allows admin to add tracks to any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("RemoveTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to remove tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to reorder", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.Reordered).To(BeTrue())
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
})

View File

@@ -0,0 +1,95 @@
package playlists
import (
"context"
"errors"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// --- REST adapter (follows Share/Library pattern) ---
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
return &playlistRepositoryWrapper{
ctx: ctx,
PlaylistRepository: s.ds.Playlist(ctx),
service: s,
}
}
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
// and rest.Persistable by delegating to service methods for all mutations.
type playlistRepositoryWrapper struct {
model.PlaylistRepository
ctx context.Context
service *playlists
}
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
}
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
repo := s.ds.Playlist(ctx)
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
if tracks == nil {
return nil
}
return tracks.(rest.Repository)
}
// savePlaylist creates a new playlist, assigning the owner from context.
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
usr, _ := request.UserFrom(ctx)
pls.OwnerID = usr.ID
pls.ID = "" // Force new creation
err := s.ds.Playlist(ctx).Put(pls)
if err != nil {
return "", err
}
return pls.ID, nil
}
// updatePlaylistEntity updates playlist metadata with permission checks.
// Used by the REST API wrapper.
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
return rest.ErrPermissionDenied
}
// Apply ownership change (admin only)
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
}
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
}

View File

@@ -0,0 +1,120 @@
package playlists_test
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("REST Adapter", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("NewRepository", func() {
var repo rest.Persistable
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
ps = playlists.NewPlaylists(ds)
})
Describe("Save", func() {
It("sets the owner from the context user", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "New Playlist"}
id, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(pls.OwnerID).To(Equal("user-1"))
})
It("forces a new creation by clearing ID", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
})
})
Describe("Update", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("denies regular user from changing ownership", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("nonexistent", pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
})
Describe("Delete", func() {
It("delegates to service Delete with permission checks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})
})
})

View File

@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
func (s *localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
if _, err := os.Stat(path); err != nil {
if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil

View File

@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
)
@@ -16,7 +17,7 @@ var Set = wire.NewSet(
NewArchiver,
NewPlayers,
NewShare,
NewPlaylists,
playlists.NewPlaylists,
NewLibrary,
NewUser,
NewMaintenance,

View File

@@ -10,6 +10,7 @@ import (
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
_ "github.com/navidrome/navidrome/db/spellfix"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/singleton"

View File

@@ -0,0 +1,391 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
}
// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
func stripPunct(col string) string {
return fmt.Sprintf(
`REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
col,
)
}
func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
// Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_participants to media_file: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to media_file: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_participants to album: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to album: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to artist: %w", err)
}
// Step 2: Populate search_participants from participants JSON.
// Extract all "name" values from the participants JSON structure.
// participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
// We use json_each + json_extract to flatten all names into a space-separated string.
_, err = tx.ExecContext(ctx, `
UPDATE media_file SET search_participants = COALESCE(
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
FROM json_each(media_file.participants) AS je1,
json_each(je1.value) AS je2
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
''
)
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
`)
if err != nil {
return fmt.Errorf("populating media_file search_participants: %w", err)
}
_, err = tx.ExecContext(ctx, `
UPDATE album SET search_participants = COALESCE(
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
FROM json_each(album.participants) AS je1,
json_each(je1.value) AS je2
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
''
)
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
`)
if err != nil {
return fmt.Errorf("populating album search_participants: %w", err)
}
// Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
// The Go code will compute the precise value on next scan; this is a best-effort approximation.
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE artist SET search_normalized = %s
WHERE name != %s`,
stripPunct("name"), stripPunct("name")))
if err != nil {
return fmt.Errorf("populating artist search_normalized: %w", err)
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
WHERE name != %s OR COALESCE(album_artist, '') != %s`,
stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
if err != nil {
return fmt.Errorf("populating album search_normalized: %w", err)
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE media_file SET search_normalized =
TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
WHERE title != %s
OR COALESCE(album, '') != %s
OR COALESCE(artist, '') != %s
OR COALESCE(album_artist, '') != %s`,
stripPunct("title"), stripPunct("COALESCE(album, '')"),
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
stripPunct("title"), stripPunct("COALESCE(album, '')"),
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
if err != nil {
return fmt.Errorf("populating media_file search_normalized: %w", err)
}
// Step 3: Create FTS5 virtual tables
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating media_file_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating album_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
name, sort_artist_name, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating artist_fts: %w", err)
}
// Step 4: Bulk-populate FTS5 indexes from existing data
_, err = tx.ExecContext(ctx, `
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
SELECT rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
COALESCE(search_normalized, '')
FROM media_file
`)
if err != nil {
return fmt.Errorf("populating media_file_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
COALESCE(search_participants, ''), COALESCE(discs, ''),
COALESCE(catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(album.tags, '$.albumversion') AS je), ''),
COALESCE(search_normalized, '')
FROM album
`)
if err != nil {
return fmt.Errorf("populating album_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
FROM artist
`)
if err != nil {
return fmt.Errorf("populating artist_fts: %w", err)
}
// Step 5: Create triggers for media_file
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
WHEN
OLD.title IS NOT NEW.title OR
OLD.album IS NOT NEW.album OR
OLD.artist IS NOT NEW.artist OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.sort_title IS NOT NEW.sort_title OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts update trigger: %w", err)
}
// Step 6: Create triggers for album
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.discs IS NOT NEW.discs OR
OLD.catalog_num IS NOT NEW.catalog_num OR
OLD.tags IS NOT NEW.tags OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts update trigger: %w", err)
}
// Step 7: Create triggers for artist
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts update trigger: %w", err)
}
return nil
}
func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
for _, trigger := range []string{
"media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
"album_fts_ai", "album_fts_ad", "album_fts_au",
"artist_fts_ai", "artist_fts_ad", "artist_fts_au",
} {
_, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
if err != nil {
return fmt.Errorf("dropping trigger %s: %w", trigger, err)
}
}
for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
_, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
if err != nil {
return fmt.Errorf("dropping table %s: %w", table, err)
}
}
// Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
// on older versions, and the column is harmless if left in place.
return nil
}

24
db/spellfix/README.md Normal file
View File

@@ -0,0 +1,24 @@
# spellfix1 SQLite Extension
This package statically compiles the [spellfix1](https://sqlite.org/spellfix1.html) SQLite extension
into the Navidrome binary. It is registered via `sqlite3_auto_extension` so that every new SQLite
connection has `spellfix1` available without loading a shared library.
## Vendored Files
The C source files are vendored because cgo cannot reference headers from other Go modules:
- **`spellfix.c`** — from the SQLite source tree: [`ext/misc/spellfix.c`](https://github.com/sqlite/sqlite/blob/master/ext/misc/spellfix.c)
- **`sqlite3ext.h`** — from the SQLite source tree: [`src/sqlite3ext.h`](https://github.com/sqlite/sqlite/blob/master/src/sqlite3ext.h)
## Updating
When upgrading `github.com/mattn/go-sqlite3`, run the update script to download
the matching spellfix1 source files for the bundled SQLite version:
```bash
./db/spellfix/update.sh
```
The script reads the SQLite version from go-sqlite3's `sqlite3-binding.h` and
downloads the corresponding files from the [SQLite GitHub mirror](https://github.com/sqlite/sqlite).

3076
db/spellfix/spellfix.c Normal file
View File

File diff suppressed because it is too large Load Diff

33
db/spellfix/spellfix.go Normal file
View File

@@ -0,0 +1,33 @@
//go:build sqlite_spellfix
package spellfix
/*
#cgo CFLAGS: -I${SRCDIR} -Wno-deprecated-declarations
// Avoid duplicate symbol conflicts with go-sqlite3.
// Rename the api pointer and entry point to unique names for this compilation unit.
#define sqlite3_api sqlite3_api_spellfix
#define sqlite3_spellfix_init sqlite3_spellfix_init_local
// Compile the extension into this binary.
// spellfix.c includes sqlite3ext.h and declares SQLITE_EXTENSION_INIT1.
#include "spellfix.c"
// sqlite3ext.h redefines sqlite3_auto_extension as a macro through the api
// struct. Undo that so we can call the real C function directly.
#undef sqlite3_auto_extension
// Provided by the SQLite library linked via go-sqlite3.
extern int sqlite3_auto_extension(void(*)(void));
// Register spellfix so it is available on every new sqlite3_open() connection.
static void register_spellfix(void) {
sqlite3_auto_extension((void(*)(void))sqlite3_spellfix_init_local);
}
*/
import "C"
func init() {
C.register_spellfix()
}

View File

@@ -0,0 +1,57 @@
//go:build sqlite_spellfix
package spellfix_test
import (
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
_ "github.com/navidrome/navidrome/db/spellfix"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSpellfix(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Spellfix Suite")
}
var _ = Describe("spellfix1", func() {
var db *sql.DB
BeforeEach(func() {
var err error
db, err = sql.Open("sqlite3", ":memory:")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
_ = db.Close()
})
It("creates a spellfix1 virtual table", func() {
_, err := db.Exec("CREATE VIRTUAL TABLE demo USING spellfix1")
Expect(err).ToNot(HaveOccurred())
})
It("returns fuzzy matches", func() {
_, err := db.Exec("CREATE VIRTUAL TABLE demo USING spellfix1")
Expect(err).ToNot(HaveOccurred())
_, err = db.Exec("INSERT INTO demo(word) VALUES ('hello'), ('world'), ('help')")
Expect(err).ToNot(HaveOccurred())
rows, err := db.Query("SELECT word FROM demo WHERE word MATCH 'helo' AND top=3")
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
var words []string
for rows.Next() {
var word string
Expect(rows.Scan(&word)).To(Succeed())
words = append(words, word)
}
Expect(words).To(ContainElement("hello"))
})
})

730
db/spellfix/sqlite3ext.h Normal file
View File

@@ -0,0 +1,730 @@
/*
** 2006 June 7
**
** The author disclaims copyright to this source code. In place of
** a legal notice, here is a blessing:
**
** May you do good and not evil.
** May you find forgiveness for yourself and forgive others.
** May you share freely, never taking more than you give.
**
*************************************************************************
** This header file defines the SQLite interface for use by
** shared libraries that want to be imported as extensions into
** an SQLite instance. Shared libraries that intend to be loaded
** as extensions by SQLite should #include this file instead of
** sqlite3.h.
*/
#ifndef SQLITE3EXT_H
#define SQLITE3EXT_H
#include "sqlite3.h"
/*
** The following structure holds pointers to all of the SQLite API
** routines.
**
** WARNING: In order to maintain backwards compatibility, add new
** interfaces to the end of this structure only. If you insert new
** interfaces in the middle of this structure, then older different
** versions of SQLite will not be able to load each other's shared
** libraries!
*/
struct sqlite3_api_routines {
void * (*aggregate_context)(sqlite3_context*,int nBytes);
int (*aggregate_count)(sqlite3_context*);
int (*bind_blob)(sqlite3_stmt*,int,const void*,int n,void(*)(void*));
int (*bind_double)(sqlite3_stmt*,int,double);
int (*bind_int)(sqlite3_stmt*,int,int);
int (*bind_int64)(sqlite3_stmt*,int,sqlite_int64);
int (*bind_null)(sqlite3_stmt*,int);
int (*bind_parameter_count)(sqlite3_stmt*);
int (*bind_parameter_index)(sqlite3_stmt*,const char*zName);
const char * (*bind_parameter_name)(sqlite3_stmt*,int);
int (*bind_text)(sqlite3_stmt*,int,const char*,int n,void(*)(void*));
int (*bind_text16)(sqlite3_stmt*,int,const void*,int,void(*)(void*));
int (*bind_value)(sqlite3_stmt*,int,const sqlite3_value*);
int (*busy_handler)(sqlite3*,int(*)(void*,int),void*);
int (*busy_timeout)(sqlite3*,int ms);
int (*changes)(sqlite3*);
int (*close)(sqlite3*);
int (*collation_needed)(sqlite3*,void*,void(*)(void*,sqlite3*,
int eTextRep,const char*));
int (*collation_needed16)(sqlite3*,void*,void(*)(void*,sqlite3*,
int eTextRep,const void*));
const void * (*column_blob)(sqlite3_stmt*,int iCol);
int (*column_bytes)(sqlite3_stmt*,int iCol);
int (*column_bytes16)(sqlite3_stmt*,int iCol);
int (*column_count)(sqlite3_stmt*pStmt);
const char * (*column_database_name)(sqlite3_stmt*,int);
const void * (*column_database_name16)(sqlite3_stmt*,int);
const char * (*column_decltype)(sqlite3_stmt*,int i);
const void * (*column_decltype16)(sqlite3_stmt*,int);
double (*column_double)(sqlite3_stmt*,int iCol);
int (*column_int)(sqlite3_stmt*,int iCol);
sqlite_int64 (*column_int64)(sqlite3_stmt*,int iCol);
const char * (*column_name)(sqlite3_stmt*,int);
const void * (*column_name16)(sqlite3_stmt*,int);
const char * (*column_origin_name)(sqlite3_stmt*,int);
const void * (*column_origin_name16)(sqlite3_stmt*,int);
const char * (*column_table_name)(sqlite3_stmt*,int);
const void * (*column_table_name16)(sqlite3_stmt*,int);
const unsigned char * (*column_text)(sqlite3_stmt*,int iCol);
const void * (*column_text16)(sqlite3_stmt*,int iCol);
int (*column_type)(sqlite3_stmt*,int iCol);
sqlite3_value* (*column_value)(sqlite3_stmt*,int iCol);
void * (*commit_hook)(sqlite3*,int(*)(void*),void*);
int (*complete)(const char*sql);
int (*complete16)(const void*sql);
int (*create_collation)(sqlite3*,const char*,int,void*,
int(*)(void*,int,const void*,int,const void*));
int (*create_collation16)(sqlite3*,const void*,int,void*,
int(*)(void*,int,const void*,int,const void*));
int (*create_function)(sqlite3*,const char*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*));
int (*create_function16)(sqlite3*,const void*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*));
int (*create_module)(sqlite3*,const char*,const sqlite3_module*,void*);
int (*data_count)(sqlite3_stmt*pStmt);
sqlite3 * (*db_handle)(sqlite3_stmt*);
int (*declare_vtab)(sqlite3*,const char*);
int (*enable_shared_cache)(int);
int (*errcode)(sqlite3*db);
const char * (*errmsg)(sqlite3*);
const void * (*errmsg16)(sqlite3*);
int (*exec)(sqlite3*,const char*,sqlite3_callback,void*,char**);
int (*expired)(sqlite3_stmt*);
int (*finalize)(sqlite3_stmt*pStmt);
void (*free)(void*);
void (*free_table)(char**result);
int (*get_autocommit)(sqlite3*);
void * (*get_auxdata)(sqlite3_context*,int);
int (*get_table)(sqlite3*,const char*,char***,int*,int*,char**);
int (*global_recover)(void);
void (*interruptx)(sqlite3*);
sqlite_int64 (*last_insert_rowid)(sqlite3*);
const char * (*libversion)(void);
int (*libversion_number)(void);
void *(*malloc)(int);
char * (*mprintf)(const char*,...);
int (*open)(const char*,sqlite3**);
int (*open16)(const void*,sqlite3**);
int (*prepare)(sqlite3*,const char*,int,sqlite3_stmt**,const char**);
int (*prepare16)(sqlite3*,const void*,int,sqlite3_stmt**,const void**);
void * (*profile)(sqlite3*,void(*)(void*,const char*,sqlite_uint64),void*);
void (*progress_handler)(sqlite3*,int,int(*)(void*),void*);
void *(*realloc)(void*,int);
int (*reset)(sqlite3_stmt*pStmt);
void (*result_blob)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_double)(sqlite3_context*,double);
void (*result_error)(sqlite3_context*,const char*,int);
void (*result_error16)(sqlite3_context*,const void*,int);
void (*result_int)(sqlite3_context*,int);
void (*result_int64)(sqlite3_context*,sqlite_int64);
void (*result_null)(sqlite3_context*);
void (*result_text)(sqlite3_context*,const char*,int,void(*)(void*));
void (*result_text16)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_text16be)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_text16le)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_value)(sqlite3_context*,sqlite3_value*);
void * (*rollback_hook)(sqlite3*,void(*)(void*),void*);
int (*set_authorizer)(sqlite3*,int(*)(void*,int,const char*,const char*,
const char*,const char*),void*);
void (*set_auxdata)(sqlite3_context*,int,void*,void (*)(void*));
char * (*xsnprintf)(int,char*,const char*,...);
int (*step)(sqlite3_stmt*);
int (*table_column_metadata)(sqlite3*,const char*,const char*,const char*,
char const**,char const**,int*,int*,int*);
void (*thread_cleanup)(void);
int (*total_changes)(sqlite3*);
void * (*trace)(sqlite3*,void(*xTrace)(void*,const char*),void*);
int (*transfer_bindings)(sqlite3_stmt*,sqlite3_stmt*);
void * (*update_hook)(sqlite3*,void(*)(void*,int ,char const*,char const*,
sqlite_int64),void*);
void * (*user_data)(sqlite3_context*);
const void * (*value_blob)(sqlite3_value*);
int (*value_bytes)(sqlite3_value*);
int (*value_bytes16)(sqlite3_value*);
double (*value_double)(sqlite3_value*);
int (*value_int)(sqlite3_value*);
sqlite_int64 (*value_int64)(sqlite3_value*);
int (*value_numeric_type)(sqlite3_value*);
const unsigned char * (*value_text)(sqlite3_value*);
const void * (*value_text16)(sqlite3_value*);
const void * (*value_text16be)(sqlite3_value*);
const void * (*value_text16le)(sqlite3_value*);
int (*value_type)(sqlite3_value*);
char *(*vmprintf)(const char*,va_list);
/* Added ??? */
int (*overload_function)(sqlite3*, const char *zFuncName, int nArg);
/* Added by 3.3.13 */
int (*prepare_v2)(sqlite3*,const char*,int,sqlite3_stmt**,const char**);
int (*prepare16_v2)(sqlite3*,const void*,int,sqlite3_stmt**,const void**);
int (*clear_bindings)(sqlite3_stmt*);
/* Added by 3.4.1 */
int (*create_module_v2)(sqlite3*,const char*,const sqlite3_module*,void*,
void (*xDestroy)(void *));
/* Added by 3.5.0 */
int (*bind_zeroblob)(sqlite3_stmt*,int,int);
int (*blob_bytes)(sqlite3_blob*);
int (*blob_close)(sqlite3_blob*);
int (*blob_open)(sqlite3*,const char*,const char*,const char*,sqlite3_int64,
int,sqlite3_blob**);
int (*blob_read)(sqlite3_blob*,void*,int,int);
int (*blob_write)(sqlite3_blob*,const void*,int,int);
int (*create_collation_v2)(sqlite3*,const char*,int,void*,
int(*)(void*,int,const void*,int,const void*),
void(*)(void*));
int (*file_control)(sqlite3*,const char*,int,void*);
sqlite3_int64 (*memory_highwater)(int);
sqlite3_int64 (*memory_used)(void);
sqlite3_mutex *(*mutex_alloc)(int);
void (*mutex_enter)(sqlite3_mutex*);
void (*mutex_free)(sqlite3_mutex*);
void (*mutex_leave)(sqlite3_mutex*);
int (*mutex_try)(sqlite3_mutex*);
int (*open_v2)(const char*,sqlite3**,int,const char*);
int (*release_memory)(int);
void (*result_error_nomem)(sqlite3_context*);
void (*result_error_toobig)(sqlite3_context*);
int (*sleep)(int);
void (*soft_heap_limit)(int);
sqlite3_vfs *(*vfs_find)(const char*);
int (*vfs_register)(sqlite3_vfs*,int);
int (*vfs_unregister)(sqlite3_vfs*);
int (*xthreadsafe)(void);
void (*result_zeroblob)(sqlite3_context*,int);
void (*result_error_code)(sqlite3_context*,int);
int (*test_control)(int, ...);
void (*randomness)(int,void*);
sqlite3 *(*context_db_handle)(sqlite3_context*);
int (*extended_result_codes)(sqlite3*,int);
int (*limit)(sqlite3*,int,int);
sqlite3_stmt *(*next_stmt)(sqlite3*,sqlite3_stmt*);
const char *(*sql)(sqlite3_stmt*);
int (*status)(int,int*,int*,int);
int (*backup_finish)(sqlite3_backup*);
sqlite3_backup *(*backup_init)(sqlite3*,const char*,sqlite3*,const char*);
int (*backup_pagecount)(sqlite3_backup*);
int (*backup_remaining)(sqlite3_backup*);
int (*backup_step)(sqlite3_backup*,int);
const char *(*compileoption_get)(int);
int (*compileoption_used)(const char*);
int (*create_function_v2)(sqlite3*,const char*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*),
void(*xDestroy)(void*));
int (*db_config)(sqlite3*,int,...);
sqlite3_mutex *(*db_mutex)(sqlite3*);
int (*db_status)(sqlite3*,int,int*,int*,int);
int (*extended_errcode)(sqlite3*);
void (*log)(int,const char*,...);
sqlite3_int64 (*soft_heap_limit64)(sqlite3_int64);
const char *(*sourceid)(void);
int (*stmt_status)(sqlite3_stmt*,int,int);
int (*strnicmp)(const char*,const char*,int);
int (*unlock_notify)(sqlite3*,void(*)(void**,int),void*);
int (*wal_autocheckpoint)(sqlite3*,int);
int (*wal_checkpoint)(sqlite3*,const char*);
void *(*wal_hook)(sqlite3*,int(*)(void*,sqlite3*,const char*,int),void*);
int (*blob_reopen)(sqlite3_blob*,sqlite3_int64);
int (*vtab_config)(sqlite3*,int op,...);
int (*vtab_on_conflict)(sqlite3*);
/* Version 3.7.16 and later */
int (*close_v2)(sqlite3*);
const char *(*db_filename)(sqlite3*,const char*);
int (*db_readonly)(sqlite3*,const char*);
int (*db_release_memory)(sqlite3*);
const char *(*errstr)(int);
int (*stmt_busy)(sqlite3_stmt*);
int (*stmt_readonly)(sqlite3_stmt*);
int (*stricmp)(const char*,const char*);
int (*uri_boolean)(const char*,const char*,int);
sqlite3_int64 (*uri_int64)(const char*,const char*,sqlite3_int64);
const char *(*uri_parameter)(const char*,const char*);
char *(*xvsnprintf)(int,char*,const char*,va_list);
int (*wal_checkpoint_v2)(sqlite3*,const char*,int,int*,int*);
/* Version 3.8.7 and later */
int (*auto_extension)(void(*)(void));
int (*bind_blob64)(sqlite3_stmt*,int,const void*,sqlite3_uint64,
void(*)(void*));
int (*bind_text64)(sqlite3_stmt*,int,const char*,sqlite3_uint64,
void(*)(void*),unsigned char);
int (*cancel_auto_extension)(void(*)(void));
int (*load_extension)(sqlite3*,const char*,const char*,char**);
void *(*malloc64)(sqlite3_uint64);
sqlite3_uint64 (*msize)(void*);
void *(*realloc64)(void*,sqlite3_uint64);
void (*reset_auto_extension)(void);
void (*result_blob64)(sqlite3_context*,const void*,sqlite3_uint64,
void(*)(void*));
void (*result_text64)(sqlite3_context*,const char*,sqlite3_uint64,
void(*)(void*), unsigned char);
int (*strglob)(const char*,const char*);
/* Version 3.8.11 and later */
sqlite3_value *(*value_dup)(const sqlite3_value*);
void (*value_free)(sqlite3_value*);
int (*result_zeroblob64)(sqlite3_context*,sqlite3_uint64);
int (*bind_zeroblob64)(sqlite3_stmt*, int, sqlite3_uint64);
/* Version 3.9.0 and later */
unsigned int (*value_subtype)(sqlite3_value*);
void (*result_subtype)(sqlite3_context*,unsigned int);
/* Version 3.10.0 and later */
int (*status64)(int,sqlite3_int64*,sqlite3_int64*,int);
int (*strlike)(const char*,const char*,unsigned int);
int (*db_cacheflush)(sqlite3*);
/* Version 3.12.0 and later */
int (*system_errno)(sqlite3*);
/* Version 3.14.0 and later */
int (*trace_v2)(sqlite3*,unsigned,int(*)(unsigned,void*,void*,void*),void*);
char *(*expanded_sql)(sqlite3_stmt*);
/* Version 3.18.0 and later */
void (*set_last_insert_rowid)(sqlite3*,sqlite3_int64);
/* Version 3.20.0 and later */
int (*prepare_v3)(sqlite3*,const char*,int,unsigned int,
sqlite3_stmt**,const char**);
int (*prepare16_v3)(sqlite3*,const void*,int,unsigned int,
sqlite3_stmt**,const void**);
int (*bind_pointer)(sqlite3_stmt*,int,void*,const char*,void(*)(void*));
void (*result_pointer)(sqlite3_context*,void*,const char*,void(*)(void*));
void *(*value_pointer)(sqlite3_value*,const char*);
int (*vtab_nochange)(sqlite3_context*);
int (*value_nochange)(sqlite3_value*);
const char *(*vtab_collation)(sqlite3_index_info*,int);
/* Version 3.24.0 and later */
int (*keyword_count)(void);
int (*keyword_name)(int,const char**,int*);
int (*keyword_check)(const char*,int);
sqlite3_str *(*str_new)(sqlite3*);
char *(*str_finish)(sqlite3_str*);
void (*str_appendf)(sqlite3_str*, const char *zFormat, ...);
void (*str_vappendf)(sqlite3_str*, const char *zFormat, va_list);
void (*str_append)(sqlite3_str*, const char *zIn, int N);
void (*str_appendall)(sqlite3_str*, const char *zIn);
void (*str_appendchar)(sqlite3_str*, int N, char C);
void (*str_reset)(sqlite3_str*);
int (*str_errcode)(sqlite3_str*);
int (*str_length)(sqlite3_str*);
char *(*str_value)(sqlite3_str*);
/* Version 3.25.0 and later */
int (*create_window_function)(sqlite3*,const char*,int,int,void*,
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*),
void (*xValue)(sqlite3_context*),
void (*xInv)(sqlite3_context*,int,sqlite3_value**),
void(*xDestroy)(void*));
/* Version 3.26.0 and later */
const char *(*normalized_sql)(sqlite3_stmt*);
/* Version 3.28.0 and later */
int (*stmt_isexplain)(sqlite3_stmt*);
int (*value_frombind)(sqlite3_value*);
/* Version 3.30.0 and later */
int (*drop_modules)(sqlite3*,const char**);
/* Version 3.31.0 and later */
sqlite3_int64 (*hard_heap_limit64)(sqlite3_int64);
const char *(*uri_key)(const char*,int);
const char *(*filename_database)(const char*);
const char *(*filename_journal)(const char*);
const char *(*filename_wal)(const char*);
/* Version 3.32.0 and later */
const char *(*create_filename)(const char*,const char*,const char*,
int,const char**);
void (*free_filename)(const char*);
sqlite3_file *(*database_file_object)(const char*);
/* Version 3.34.0 and later */
int (*txn_state)(sqlite3*,const char*);
/* Version 3.36.1 and later */
sqlite3_int64 (*changes64)(sqlite3*);
sqlite3_int64 (*total_changes64)(sqlite3*);
/* Version 3.37.0 and later */
int (*autovacuum_pages)(sqlite3*,
unsigned int(*)(void*,const char*,unsigned int,unsigned int,unsigned int),
void*, void(*)(void*));
/* Version 3.38.0 and later */
int (*error_offset)(sqlite3*);
int (*vtab_rhs_value)(sqlite3_index_info*,int,sqlite3_value**);
int (*vtab_distinct)(sqlite3_index_info*);
int (*vtab_in)(sqlite3_index_info*,int,int);
int (*vtab_in_first)(sqlite3_value*,sqlite3_value**);
int (*vtab_in_next)(sqlite3_value*,sqlite3_value**);
/* Version 3.39.0 and later */
int (*deserialize)(sqlite3*,const char*,unsigned char*,
sqlite3_int64,sqlite3_int64,unsigned);
unsigned char *(*serialize)(sqlite3*,const char *,sqlite3_int64*,
unsigned int);
const char *(*db_name)(sqlite3*,int);
/* Version 3.40.0 and later */
int (*value_encoding)(sqlite3_value*);
/* Version 3.41.0 and later */
int (*is_interrupted)(sqlite3*);
/* Version 3.43.0 and later */
int (*stmt_explain)(sqlite3_stmt*,int);
/* Version 3.44.0 and later */
void *(*get_clientdata)(sqlite3*,const char*);
int (*set_clientdata)(sqlite3*, const char*, void*, void(*)(void*));
/* Version 3.50.0 and later */
int (*setlk_timeout)(sqlite3*,int,int);
/* Version 3.51.0 and later */
int (*set_errmsg)(sqlite3*,int,const char*);
int (*db_status64)(sqlite3*,int,sqlite3_int64*,sqlite3_int64*,int);
};
/*
** This is the function signature used for all extension entry points. It
** is also defined in the file "loadext.c".
*/
typedef int (*sqlite3_loadext_entry)(
sqlite3 *db, /* Handle to the database. */
char **pzErrMsg, /* Used to set error string on failure. */
const sqlite3_api_routines *pThunk /* Extension API function pointers. */
);
/*
** The following macros redefine the API routines so that they are
** redirected through the global sqlite3_api structure.
**
** This header file is also used by the loadext.c source file
** (part of the main SQLite library - not an extension) so that
** it can get access to the sqlite3_api_routines structure
** definition. But the main library does not want to redefine
** the API. So the redefinition macros are only valid if the
** SQLITE_CORE macros is undefined.
*/
#if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION)
#define sqlite3_aggregate_context sqlite3_api->aggregate_context
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_aggregate_count sqlite3_api->aggregate_count
#endif
#define sqlite3_bind_blob sqlite3_api->bind_blob
#define sqlite3_bind_double sqlite3_api->bind_double
#define sqlite3_bind_int sqlite3_api->bind_int
#define sqlite3_bind_int64 sqlite3_api->bind_int64
#define sqlite3_bind_null sqlite3_api->bind_null
#define sqlite3_bind_parameter_count sqlite3_api->bind_parameter_count
#define sqlite3_bind_parameter_index sqlite3_api->bind_parameter_index
#define sqlite3_bind_parameter_name sqlite3_api->bind_parameter_name
#define sqlite3_bind_text sqlite3_api->bind_text
#define sqlite3_bind_text16 sqlite3_api->bind_text16
#define sqlite3_bind_value sqlite3_api->bind_value
#define sqlite3_busy_handler sqlite3_api->busy_handler
#define sqlite3_busy_timeout sqlite3_api->busy_timeout
#define sqlite3_changes sqlite3_api->changes
#define sqlite3_close sqlite3_api->close
#define sqlite3_collation_needed sqlite3_api->collation_needed
#define sqlite3_collation_needed16 sqlite3_api->collation_needed16
#define sqlite3_column_blob sqlite3_api->column_blob
#define sqlite3_column_bytes sqlite3_api->column_bytes
#define sqlite3_column_bytes16 sqlite3_api->column_bytes16
#define sqlite3_column_count sqlite3_api->column_count
#define sqlite3_column_database_name sqlite3_api->column_database_name
#define sqlite3_column_database_name16 sqlite3_api->column_database_name16
#define sqlite3_column_decltype sqlite3_api->column_decltype
#define sqlite3_column_decltype16 sqlite3_api->column_decltype16
#define sqlite3_column_double sqlite3_api->column_double
#define sqlite3_column_int sqlite3_api->column_int
#define sqlite3_column_int64 sqlite3_api->column_int64
#define sqlite3_column_name sqlite3_api->column_name
#define sqlite3_column_name16 sqlite3_api->column_name16
#define sqlite3_column_origin_name sqlite3_api->column_origin_name
#define sqlite3_column_origin_name16 sqlite3_api->column_origin_name16
#define sqlite3_column_table_name sqlite3_api->column_table_name
#define sqlite3_column_table_name16 sqlite3_api->column_table_name16
#define sqlite3_column_text sqlite3_api->column_text
#define sqlite3_column_text16 sqlite3_api->column_text16
#define sqlite3_column_type sqlite3_api->column_type
#define sqlite3_column_value sqlite3_api->column_value
#define sqlite3_commit_hook sqlite3_api->commit_hook
#define sqlite3_complete sqlite3_api->complete
#define sqlite3_complete16 sqlite3_api->complete16
#define sqlite3_create_collation sqlite3_api->create_collation
#define sqlite3_create_collation16 sqlite3_api->create_collation16
#define sqlite3_create_function sqlite3_api->create_function
#define sqlite3_create_function16 sqlite3_api->create_function16
#define sqlite3_create_module sqlite3_api->create_module
#define sqlite3_create_module_v2 sqlite3_api->create_module_v2
#define sqlite3_data_count sqlite3_api->data_count
#define sqlite3_db_handle sqlite3_api->db_handle
#define sqlite3_declare_vtab sqlite3_api->declare_vtab
#define sqlite3_enable_shared_cache sqlite3_api->enable_shared_cache
#define sqlite3_errcode sqlite3_api->errcode
#define sqlite3_errmsg sqlite3_api->errmsg
#define sqlite3_errmsg16 sqlite3_api->errmsg16
#define sqlite3_exec sqlite3_api->exec
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_expired sqlite3_api->expired
#endif
#define sqlite3_finalize sqlite3_api->finalize
#define sqlite3_free sqlite3_api->free
#define sqlite3_free_table sqlite3_api->free_table
#define sqlite3_get_autocommit sqlite3_api->get_autocommit
#define sqlite3_get_auxdata sqlite3_api->get_auxdata
#define sqlite3_get_table sqlite3_api->get_table
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_global_recover sqlite3_api->global_recover
#endif
#define sqlite3_interrupt sqlite3_api->interruptx
#define sqlite3_last_insert_rowid sqlite3_api->last_insert_rowid
#define sqlite3_libversion sqlite3_api->libversion
#define sqlite3_libversion_number sqlite3_api->libversion_number
#define sqlite3_malloc sqlite3_api->malloc
#define sqlite3_mprintf sqlite3_api->mprintf
#define sqlite3_open sqlite3_api->open
#define sqlite3_open16 sqlite3_api->open16
#define sqlite3_prepare sqlite3_api->prepare
#define sqlite3_prepare16 sqlite3_api->prepare16
#define sqlite3_prepare_v2 sqlite3_api->prepare_v2
#define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2
#define sqlite3_profile sqlite3_api->profile
#define sqlite3_progress_handler sqlite3_api->progress_handler
#define sqlite3_realloc sqlite3_api->realloc
#define sqlite3_reset sqlite3_api->reset
#define sqlite3_result_blob sqlite3_api->result_blob
#define sqlite3_result_double sqlite3_api->result_double
#define sqlite3_result_error sqlite3_api->result_error
#define sqlite3_result_error16 sqlite3_api->result_error16
#define sqlite3_result_int sqlite3_api->result_int
#define sqlite3_result_int64 sqlite3_api->result_int64
#define sqlite3_result_null sqlite3_api->result_null
#define sqlite3_result_text sqlite3_api->result_text
#define sqlite3_result_text16 sqlite3_api->result_text16
#define sqlite3_result_text16be sqlite3_api->result_text16be
#define sqlite3_result_text16le sqlite3_api->result_text16le
#define sqlite3_result_value sqlite3_api->result_value
#define sqlite3_rollback_hook sqlite3_api->rollback_hook
#define sqlite3_set_authorizer sqlite3_api->set_authorizer
#define sqlite3_set_auxdata sqlite3_api->set_auxdata
#define sqlite3_snprintf sqlite3_api->xsnprintf
#define sqlite3_step sqlite3_api->step
#define sqlite3_table_column_metadata sqlite3_api->table_column_metadata
#define sqlite3_thread_cleanup sqlite3_api->thread_cleanup
#define sqlite3_total_changes sqlite3_api->total_changes
#define sqlite3_trace sqlite3_api->trace
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_transfer_bindings sqlite3_api->transfer_bindings
#endif
#define sqlite3_update_hook sqlite3_api->update_hook
#define sqlite3_user_data sqlite3_api->user_data
#define sqlite3_value_blob sqlite3_api->value_blob
#define sqlite3_value_bytes sqlite3_api->value_bytes
#define sqlite3_value_bytes16 sqlite3_api->value_bytes16
#define sqlite3_value_double sqlite3_api->value_double
#define sqlite3_value_int sqlite3_api->value_int
#define sqlite3_value_int64 sqlite3_api->value_int64
#define sqlite3_value_numeric_type sqlite3_api->value_numeric_type
#define sqlite3_value_text sqlite3_api->value_text
#define sqlite3_value_text16 sqlite3_api->value_text16
#define sqlite3_value_text16be sqlite3_api->value_text16be
#define sqlite3_value_text16le sqlite3_api->value_text16le
#define sqlite3_value_type sqlite3_api->value_type
#define sqlite3_vmprintf sqlite3_api->vmprintf
#define sqlite3_vsnprintf sqlite3_api->xvsnprintf
#define sqlite3_overload_function sqlite3_api->overload_function
#define sqlite3_prepare_v2 sqlite3_api->prepare_v2
#define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2
#define sqlite3_clear_bindings sqlite3_api->clear_bindings
#define sqlite3_bind_zeroblob sqlite3_api->bind_zeroblob
#define sqlite3_blob_bytes sqlite3_api->blob_bytes
#define sqlite3_blob_close sqlite3_api->blob_close
#define sqlite3_blob_open sqlite3_api->blob_open
#define sqlite3_blob_read sqlite3_api->blob_read
#define sqlite3_blob_write sqlite3_api->blob_write
#define sqlite3_create_collation_v2 sqlite3_api->create_collation_v2
#define sqlite3_file_control sqlite3_api->file_control
#define sqlite3_memory_highwater sqlite3_api->memory_highwater
#define sqlite3_memory_used sqlite3_api->memory_used
#define sqlite3_mutex_alloc sqlite3_api->mutex_alloc
#define sqlite3_mutex_enter sqlite3_api->mutex_enter
#define sqlite3_mutex_free sqlite3_api->mutex_free
#define sqlite3_mutex_leave sqlite3_api->mutex_leave
#define sqlite3_mutex_try sqlite3_api->mutex_try
#define sqlite3_open_v2 sqlite3_api->open_v2
#define sqlite3_release_memory sqlite3_api->release_memory
#define sqlite3_result_error_nomem sqlite3_api->result_error_nomem
#define sqlite3_result_error_toobig sqlite3_api->result_error_toobig
#define sqlite3_sleep sqlite3_api->sleep
#define sqlite3_soft_heap_limit sqlite3_api->soft_heap_limit
#define sqlite3_vfs_find sqlite3_api->vfs_find
#define sqlite3_vfs_register sqlite3_api->vfs_register
#define sqlite3_vfs_unregister sqlite3_api->vfs_unregister
#define sqlite3_threadsafe sqlite3_api->xthreadsafe
#define sqlite3_result_zeroblob sqlite3_api->result_zeroblob
#define sqlite3_result_error_code sqlite3_api->result_error_code
#define sqlite3_test_control sqlite3_api->test_control
#define sqlite3_randomness sqlite3_api->randomness
#define sqlite3_context_db_handle sqlite3_api->context_db_handle
#define sqlite3_extended_result_codes sqlite3_api->extended_result_codes
#define sqlite3_limit sqlite3_api->limit
#define sqlite3_next_stmt sqlite3_api->next_stmt
#define sqlite3_sql sqlite3_api->sql
#define sqlite3_status sqlite3_api->status
#define sqlite3_backup_finish sqlite3_api->backup_finish
#define sqlite3_backup_init sqlite3_api->backup_init
#define sqlite3_backup_pagecount sqlite3_api->backup_pagecount
#define sqlite3_backup_remaining sqlite3_api->backup_remaining
#define sqlite3_backup_step sqlite3_api->backup_step
#define sqlite3_compileoption_get sqlite3_api->compileoption_get
#define sqlite3_compileoption_used sqlite3_api->compileoption_used
#define sqlite3_create_function_v2 sqlite3_api->create_function_v2
#define sqlite3_db_config sqlite3_api->db_config
#define sqlite3_db_mutex sqlite3_api->db_mutex
#define sqlite3_db_status sqlite3_api->db_status
#define sqlite3_extended_errcode sqlite3_api->extended_errcode
#define sqlite3_log sqlite3_api->log
#define sqlite3_soft_heap_limit64 sqlite3_api->soft_heap_limit64
#define sqlite3_sourceid sqlite3_api->sourceid
#define sqlite3_stmt_status sqlite3_api->stmt_status
#define sqlite3_strnicmp sqlite3_api->strnicmp
#define sqlite3_unlock_notify sqlite3_api->unlock_notify
#define sqlite3_wal_autocheckpoint sqlite3_api->wal_autocheckpoint
#define sqlite3_wal_checkpoint sqlite3_api->wal_checkpoint
#define sqlite3_wal_hook sqlite3_api->wal_hook
#define sqlite3_blob_reopen sqlite3_api->blob_reopen
#define sqlite3_vtab_config sqlite3_api->vtab_config
#define sqlite3_vtab_on_conflict sqlite3_api->vtab_on_conflict
/* Version 3.7.16 and later */
#define sqlite3_close_v2 sqlite3_api->close_v2
#define sqlite3_db_filename sqlite3_api->db_filename
#define sqlite3_db_readonly sqlite3_api->db_readonly
#define sqlite3_db_release_memory sqlite3_api->db_release_memory
#define sqlite3_errstr sqlite3_api->errstr
#define sqlite3_stmt_busy sqlite3_api->stmt_busy
#define sqlite3_stmt_readonly sqlite3_api->stmt_readonly
#define sqlite3_stricmp sqlite3_api->stricmp
#define sqlite3_uri_boolean sqlite3_api->uri_boolean
#define sqlite3_uri_int64 sqlite3_api->uri_int64
#define sqlite3_uri_parameter sqlite3_api->uri_parameter
#define sqlite3_uri_vsnprintf sqlite3_api->xvsnprintf
#define sqlite3_wal_checkpoint_v2 sqlite3_api->wal_checkpoint_v2
/* Version 3.8.7 and later */
#define sqlite3_auto_extension sqlite3_api->auto_extension
#define sqlite3_bind_blob64 sqlite3_api->bind_blob64
#define sqlite3_bind_text64 sqlite3_api->bind_text64
#define sqlite3_cancel_auto_extension sqlite3_api->cancel_auto_extension
#define sqlite3_load_extension sqlite3_api->load_extension
#define sqlite3_malloc64 sqlite3_api->malloc64
#define sqlite3_msize sqlite3_api->msize
#define sqlite3_realloc64 sqlite3_api->realloc64
#define sqlite3_reset_auto_extension sqlite3_api->reset_auto_extension
#define sqlite3_result_blob64 sqlite3_api->result_blob64
#define sqlite3_result_text64 sqlite3_api->result_text64
#define sqlite3_strglob sqlite3_api->strglob
/* Version 3.8.11 and later */
#define sqlite3_value_dup sqlite3_api->value_dup
#define sqlite3_value_free sqlite3_api->value_free
#define sqlite3_result_zeroblob64 sqlite3_api->result_zeroblob64
#define sqlite3_bind_zeroblob64 sqlite3_api->bind_zeroblob64
/* Version 3.9.0 and later */
#define sqlite3_value_subtype sqlite3_api->value_subtype
#define sqlite3_result_subtype sqlite3_api->result_subtype
/* Version 3.10.0 and later */
#define sqlite3_status64 sqlite3_api->status64
#define sqlite3_strlike sqlite3_api->strlike
#define sqlite3_db_cacheflush sqlite3_api->db_cacheflush
/* Version 3.12.0 and later */
#define sqlite3_system_errno sqlite3_api->system_errno
/* Version 3.14.0 and later */
#define sqlite3_trace_v2 sqlite3_api->trace_v2
#define sqlite3_expanded_sql sqlite3_api->expanded_sql
/* Version 3.18.0 and later */
#define sqlite3_set_last_insert_rowid sqlite3_api->set_last_insert_rowid
/* Version 3.20.0 and later */
#define sqlite3_prepare_v3 sqlite3_api->prepare_v3
#define sqlite3_prepare16_v3 sqlite3_api->prepare16_v3
#define sqlite3_bind_pointer sqlite3_api->bind_pointer
#define sqlite3_result_pointer sqlite3_api->result_pointer
#define sqlite3_value_pointer sqlite3_api->value_pointer
/* Version 3.22.0 and later */
#define sqlite3_vtab_nochange sqlite3_api->vtab_nochange
#define sqlite3_value_nochange sqlite3_api->value_nochange
#define sqlite3_vtab_collation sqlite3_api->vtab_collation
/* Version 3.24.0 and later */
#define sqlite3_keyword_count sqlite3_api->keyword_count
#define sqlite3_keyword_name sqlite3_api->keyword_name
#define sqlite3_keyword_check sqlite3_api->keyword_check
#define sqlite3_str_new sqlite3_api->str_new
#define sqlite3_str_finish sqlite3_api->str_finish
#define sqlite3_str_appendf sqlite3_api->str_appendf
#define sqlite3_str_vappendf sqlite3_api->str_vappendf
#define sqlite3_str_append sqlite3_api->str_append
#define sqlite3_str_appendall sqlite3_api->str_appendall
#define sqlite3_str_appendchar sqlite3_api->str_appendchar
#define sqlite3_str_reset sqlite3_api->str_reset
#define sqlite3_str_errcode sqlite3_api->str_errcode
#define sqlite3_str_length sqlite3_api->str_length
#define sqlite3_str_value sqlite3_api->str_value
/* Version 3.25.0 and later */
#define sqlite3_create_window_function sqlite3_api->create_window_function
/* Version 3.26.0 and later */
#define sqlite3_normalized_sql sqlite3_api->normalized_sql
/* Version 3.28.0 and later */
#define sqlite3_stmt_isexplain sqlite3_api->stmt_isexplain
#define sqlite3_value_frombind sqlite3_api->value_frombind
/* Version 3.30.0 and later */
#define sqlite3_drop_modules sqlite3_api->drop_modules
/* Version 3.31.0 and later */
#define sqlite3_hard_heap_limit64 sqlite3_api->hard_heap_limit64
#define sqlite3_uri_key sqlite3_api->uri_key
#define sqlite3_filename_database sqlite3_api->filename_database
#define sqlite3_filename_journal sqlite3_api->filename_journal
#define sqlite3_filename_wal sqlite3_api->filename_wal
/* Version 3.32.0 and later */
#define sqlite3_create_filename sqlite3_api->create_filename
#define sqlite3_free_filename sqlite3_api->free_filename
#define sqlite3_database_file_object sqlite3_api->database_file_object
/* Version 3.34.0 and later */
#define sqlite3_txn_state sqlite3_api->txn_state
/* Version 3.36.1 and later */
#define sqlite3_changes64 sqlite3_api->changes64
#define sqlite3_total_changes64 sqlite3_api->total_changes64
/* Version 3.37.0 and later */
#define sqlite3_autovacuum_pages sqlite3_api->autovacuum_pages
/* Version 3.38.0 and later */
#define sqlite3_error_offset sqlite3_api->error_offset
#define sqlite3_vtab_rhs_value sqlite3_api->vtab_rhs_value
#define sqlite3_vtab_distinct sqlite3_api->vtab_distinct
#define sqlite3_vtab_in sqlite3_api->vtab_in
#define sqlite3_vtab_in_first sqlite3_api->vtab_in_first
#define sqlite3_vtab_in_next sqlite3_api->vtab_in_next
/* Version 3.39.0 and later */
#ifndef SQLITE_OMIT_DESERIALIZE
#define sqlite3_deserialize sqlite3_api->deserialize
#define sqlite3_serialize sqlite3_api->serialize
#endif
#define sqlite3_db_name sqlite3_api->db_name
/* Version 3.40.0 and later */
#define sqlite3_value_encoding sqlite3_api->value_encoding
/* Version 3.41.0 and later */
#define sqlite3_is_interrupted sqlite3_api->is_interrupted
/* Version 3.43.0 and later */
#define sqlite3_stmt_explain sqlite3_api->stmt_explain
/* Version 3.44.0 and later */
#define sqlite3_get_clientdata sqlite3_api->get_clientdata
#define sqlite3_set_clientdata sqlite3_api->set_clientdata
/* Version 3.50.0 and later */
#define sqlite3_setlk_timeout sqlite3_api->setlk_timeout
/* Version 3.51.0 and later */
#define sqlite3_set_errmsg sqlite3_api->set_errmsg
#define sqlite3_db_status64 sqlite3_api->db_status64
#endif /* !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) */
#if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION)
/* This case when the file really is being compiled as a loadable
** extension */
# define SQLITE_EXTENSION_INIT1 const sqlite3_api_routines *sqlite3_api=0;
# define SQLITE_EXTENSION_INIT2(v) sqlite3_api=v;
# define SQLITE_EXTENSION_INIT3 \
extern const sqlite3_api_routines *sqlite3_api;
#else
/* This case when the file is being statically linked into the
** application */
# define SQLITE_EXTENSION_INIT1 /*no-op*/
# define SQLITE_EXTENSION_INIT2(v) (void)v; /* unused parameter */
# define SQLITE_EXTENSION_INIT3 /*no-op*/
#endif
#endif /* SQLITE3EXT_H */

31
db/spellfix/update.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
#
# Updates the vendored spellfix1 source files to match the SQLite version
# bundled with the current go-sqlite3 dependency.
#
set -euo pipefail
cd "$(dirname "$0")"
SQLITE_VERSION=$(grep '#define SQLITE_VERSION ' \
"$(go env GOMODCACHE)/$(go list -m -f '{{.Path}}@{{.Version}}' github.com/mattn/go-sqlite3)/sqlite3-binding.h" \
| awk '{gsub(/"/, "", $3); print $3}')
if [ -z "$SQLITE_VERSION" ]; then
echo "ERROR: Could not determine SQLite version from go-sqlite3" >&2
exit 1
fi
TAG="version-${SQLITE_VERSION}"
BASE_URL="https://raw.githubusercontent.com/sqlite/sqlite/${TAG}"
echo "SQLite version from go-sqlite3: ${SQLITE_VERSION}"
echo "Downloading from tag: ${TAG}"
curl -sfL "${BASE_URL}/ext/misc/spellfix.c" -o spellfix.c
echo " Updated spellfix.c"
curl -sfL "${BASE_URL}/src/sqlite3ext.h" -o sqlite3ext.h
echo " Updated sqlite3ext.h"
echo "Done."

6
go.mod
View File

@@ -7,7 +7,7 @@ replace (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e
)
require (
@@ -46,13 +46,13 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/mattn/go-sqlite3 v1.14.34
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.26.0
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3

12
go.sum
View File

@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I=
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=

View File

@@ -9,11 +9,13 @@ import (
//goland:noinspection GoBoolExpressions
func main() {
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
// These references force the inclusion of build tags when compiling the project.
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
// the `netgo` build tag when compiling the project.
// the required build tags when compiling the project.
// To avoid these kind of errors, you should use `make build` to compile the project.
_ = buildtags.NETGO
_ = buildtags.SQLITE_FTS5
_ = buildtags.SQLITE_SPELLFIX
cmd.Execute()
}

View File

@@ -95,6 +95,25 @@ func (c Criteria) ToSql() (sql string, args []any, err error) {
return c.Expression.ToSql()
}
// RequiredJoins inspects the expression tree and Sort field to determine which
// additional JOINs are needed when evaluating this criteria.
func (c Criteria) RequiredJoins() JoinType {
result := JoinNone
if c.Expression != nil {
result |= extractJoinTypes(c.Expression)
}
// Also check Sort fields
if c.Sort != "" {
for _, p := range strings.Split(c.Sort, ",") {
p = strings.TrimSpace(p)
p = strings.TrimLeft(p, "+-")
p = strings.TrimSpace(p)
result |= fieldJoinType(p)
}
}
return result
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil

View File

@@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() {
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
Gt{"albumrating": 3},
},
},
Sort: "title",
@@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() {
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "Rock" }}
{ "isNot": { "genre": "Rock" }},
{ "gt": { "albumrating": 3 } }
]
}
],
@@ -68,10 +70,10 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
`AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
`AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
@@ -172,13 +174,95 @@ var _ = Describe("Criteria", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
))
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
Describe("RequiredJoins", func() {
It("returns JoinNone when no annotation fields are used", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinNone for media_file annotation fields", func() {
c := Criteria{
Expression: All{
Is{"loved": true},
Gt{"playCount": 5},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinAlbumAnnotation for album annotation fields", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
})
It("returns JoinArtistAnnotation for artist annotation fields", func() {
c := Criteria{
Expression: All{
Is{"artistLoved": true},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
})
It("returns both join types when both are used", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
Is{"artistLoved": true},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types in nested expressions", func() {
c := Criteria{
Expression: All{
Any{
All{
Is{"albumLoved": true},
},
},
Any{
Gt{"artistPlayCount": 10},
},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field with direction prefix", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "-artistRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
})
Context("with child playlists", func() {
var (
topLevelInPlaylistID string

View File

@@ -9,45 +9,71 @@ import (
"github.com/navidrome/navidrome/log"
)
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
type JoinType int
const (
JoinNone JoinType = 0
JoinAlbumAnnotation JoinType = 1 << iota
JoinArtistAnnotation
)
// Has returns true if j contains all bits in other.
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
"mbz_album_id": {field: "media_file.mbz_album_id"},
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
@@ -65,12 +91,13 @@ var fieldMap = map[string]*mappedField{
}
type mappedField struct {
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
joinType JoinType // which additional JOINs this field requires
}
func mapFields(expr map[string]any) map[string]any {
@@ -169,7 +196,7 @@ func (e tagCond) ToSql() (string, []any, error) {
}
}
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
tagName, cond)
if e.not {
cond = "not " + cond
@@ -189,7 +216,7 @@ type roleCond struct {
func (e roleCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`,
e.role, cond)
if e.not {
cond = "not " + cond
@@ -197,6 +224,38 @@ func (e roleCond) ToSql() (string, []any, error) {
return cond, args, err
}
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
func fieldJoinType(name string) JoinType {
if f, ok := fieldMap[strings.ToLower(name)]; ok {
return f.joinType
}
return JoinNone
}
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
func extractJoinTypes(expr any) JoinType {
result := JoinNone
switch e := expr.(type) {
case All:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
case Any:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
default:
// Leaf expression: use reflection to check if it's a map with field names
rv := reflect.ValueOf(expr)
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
for _, key := range rv.MapKeys() {
result |= fieldJoinType(key.String())
}
}
}
return result
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddRoles(roles []string) {

View File

@@ -54,23 +54,43 @@ var _ = Describe("Operators", func() {
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Album annotation fields
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Artist annotation fields
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Tag tests
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
// Artist roles tests
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
// TODO Validate operators that are not valid for each field type.
@@ -88,7 +108,7 @@ var _ = Describe("Operators", func() {
op := EndsWith{"mood": "Soft"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("casts numeric comparisons", func() {
@@ -96,7 +116,7 @@ var _ = Describe("Operators", func() {
op := Lt{"rate": 6}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(args).To(gomega.HaveExactElements(6))
})
It("skips unknown tag names", func() {
@@ -110,7 +130,7 @@ var _ = Describe("Operators", func() {
op := Contains{"releasetype": "soundtrack"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
})
It("supports albumtype as alias for releasetype", func() {
@@ -118,7 +138,7 @@ var _ = Describe("Operators", func() {
op := Contains{"albumtype": "live"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
})
It("supports albumtype alias with Is operator", func() {
@@ -127,7 +147,7 @@ var _ = Describe("Operators", func() {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("album"))
})
It("supports albumtype alias with IsNot operator", func() {
@@ -136,7 +156,7 @@ var _ = Describe("Operators", func() {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
})
})
@@ -147,7 +167,7 @@ var _ = Describe("Operators", func() {
op := EndsWith{"producer": "Eno"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
})
It("skips unknown roles", func() {

View File

@@ -38,7 +38,7 @@ type MediaFile struct {
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
// AlbumArtist is the display name used for the album artist.
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
AlbumID string `structs:"album_id" json:"albumId"`
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
TrackNumber int `structs:"track_number" json:"trackNumber"`
DiscNumber int `structs:"disc_number" json:"discNumber"`

View File

@@ -1,5 +1,5 @@
package model
type SearchableRepository[T any] interface {
Search(q string, offset, size int, options ...QueryOptions) (T, error)
Search(q string, options ...QueryOptions) (T, error)
}

View File

@@ -22,7 +22,7 @@ type User struct {
Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}

View File

@@ -12,7 +12,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -62,11 +61,14 @@ func (a *dbAlbum) PostScan() error {
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
fullText = append(fullText, a.Album.Participants.AllNames()...)
participantNames := a.Album.Participants.AllNames()
fullText = append(fullText, participantNames...)
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
args["full_text"] = formatFullText(fullText...)
args["search_participants"] = strings.Join(participantNames, " ")
args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
args["tags"] = marshalTags(a.Album.Tags)
args["participants"] = marshalParticipants(a.Album.Participants)
@@ -350,18 +352,21 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
return nil
}
func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
var albumSearchConfig = searchConfig{
NaturalOrder: "album.rowid",
OrderBy: []string{"name"},
MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
}
func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbAlbums
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
if err != nil {
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
}
err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
if err != nil {
return nil, fmt.Errorf("searching album %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@@ -56,17 +56,23 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
albumWithVersion,
albumCJK,
albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
albumPunctuation,
}))
})
It("returns all records sorted desc", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumPunctuation,
albumSgtPeppers,
albumRadioactivity,
albumMultiDisc,
albumCJK,
albumWithVersion,
albumAbbeyRoad,
}))
})

View File

@@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -102,6 +101,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
similarArtists, _ := json.Marshal(sa)
m["similar_artists"] = string(similarArtists)
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
m["search_normalized"] = normalizeForFTS(a.Name)
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
// TODO: Better way to handle this?
@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
})
r.setSortMappings(map[string]string{
r.setSortMappings(map[string]string{ //nolint:gosec
"name": "order_artist_name",
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
@@ -512,20 +512,25 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
return totalRowsAffected, nil
}
func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
var res dbArtists
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
}
} else {
func (r *artistRepository) searchCfg() searchConfig {
return searchConfig{
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
"sum(json_extract(stats, '$.total.m')) desc", "name")
if err != nil {
return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
}
NaturalOrder: "artist.id",
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
MBIDFields: []string{"mbz_artist_id"},
LibraryFilter: r.applyLibraryFilterToArtistQuery,
}
}
func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbArtists
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
if err != nil {
return nil, fmt.Errorf("searching artist %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() {
Describe("Basic Operations", func() {
Describe("Count", func() {
It("returns the number of artists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
Expect(repo.CountAll()).To(Equal(int64(4)))
})
})
@@ -228,13 +228,19 @@ var _ = Describe("ArtistRepository", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("F"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// Restore the original value
artistBeatles.SortArtistName = ""
@@ -246,13 +252,19 @@ var _ = Describe("ArtistRepository", func() {
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@@ -268,13 +280,19 @@ var _ = Describe("ArtistRepository", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// Restore the original value
artistBeatles.SortArtistName = ""
@@ -285,13 +303,19 @@ var _ = Describe("ArtistRepository", func() {
It("returns the index when SortArtistName is empty", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@@ -377,7 +401,7 @@ var _ = Describe("ArtistRepository", func() {
// Admin users can see all content when valid library IDs are provided
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
// With non-existent library ID, admin users see no content because no artists are associated with that library
idx, err = repo.GetIndex(false, []int{999})
@@ -488,7 +512,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Test the search
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10)
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
if shouldFind {
@@ -519,12 +543,12 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
// But admin should find it
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
@@ -536,7 +560,7 @@ var _ = Describe("ArtistRepository", func() {
Context("Text Search", func() {
It("allows admin to find artists by name regardless of library", func() {
results, err := repo.Search("Beatles", 0, 10)
results, err := repo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("The Beatles"))
@@ -556,7 +580,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist
results, err := restrictedRepo.Search("Unique Search Name", 0, 10)
results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "Text search should respect library filtering")
@@ -625,11 +649,11 @@ var _ = Describe("ArtistRepository", func() {
It("sees all artists regardless of library permissions", func() {
count, err := repo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(2)))
Expect(count).To(Equal(int64(4)))
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
Expect(artists).To(HaveLen(4))
exists, err := repo.Exists(artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
@@ -661,10 +685,10 @@ var _ = Describe("ArtistRepository", func() {
// Should see missing artist in GetAll by default for admin users
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3)) // Including the missing artist
Expect(artists).To(HaveLen(5)) // Including the missing artist
// Search never returns missing artists (hardcoded behavior)
results, err := repo.Search("Missing Artist", 0, 10)
results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -718,11 +742,11 @@ var _ = Describe("ArtistRepository", func() {
})
It("Search returns empty results for users without library access", func() {
results, err := restrictedRepo.Search("Beatles", 0, 10)
results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
results, err = restrictedRepo.Search("Kraftwerk", 0, 10)
results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -767,19 +791,19 @@ var _ = Describe("ArtistRepository", func() {
It("CountAll returns correct count after gaining access", func() {
count, err := restrictedRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk
Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
})
It("GetAll returns artists after gaining access", func() {
artists, err := restrictedRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
Expect(artists).To(HaveLen(4))
var names []string
for _, artist := range artists {
names = append(names, artist.Name)
}
Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
})
It("Exists returns true for accessible artists", func() {
@@ -796,7 +820,7 @@ var _ = Describe("ArtistRepository", func() {
// With valid library access, should see artists
idx, err := restrictedRepo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
// With non-existent library ID, should see nothing (non-admin user)
idx, err = restrictedRepo.GetIndex(false, []int{999})

View File

@@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -58,8 +57,11 @@ func (m *dbMediaFile) PostScan() error {
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
participantNames := m.MediaFile.Participants.AllNames()
fullText = append(fullText, participantNames...)
args["full_text"] = formatFullText(fullText...)
args["search_participants"] = strings.Join(participantNames, " ")
args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
args["tags"] = marshalTags(m.MediaFile.Tags)
args["participants"] = marshalParticipants(m.MediaFile.Participants)
return nil
@@ -148,7 +150,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
m.CreatedAt = time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now()
}
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
if err != nil {
return err
@@ -423,18 +427,21 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil
return res.toModels(), nil
}
func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
var mediaFileSearchConfig = searchConfig{
NaturalOrder: "media_file.rowid",
OrderBy: []string{"title"},
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
}
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbMediaFiles
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
if err != nil {
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
}
err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
if err != nil {
return nil, fmt.Errorf("searching media_file %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(10)))
Expect(mr.CountAll()).To(Equal(int64(13)))
})
Describe("CountBySuffix", func() {
@@ -104,6 +104,68 @@ var _ = Describe("MediaRepository", func() {
}
})
Describe("Put CreatedAt behavior (#5050)", func() {
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
before := time.Now().Add(-time.Second)
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
_ = mr.Delete(newFile.ID)
})
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
newFile := model.MediaFile{
ID: id.NewRandom(),
LibraryID: 1,
Path: "/test/created-at-preserved.mp3",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(newFile.ID)
})
It("does not reset CreatedAt when updating an existing file", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
fileID := id.NewRandom()
newFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Original Title",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
// Update the file with a new title but zero CreatedAt
updatedFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Updated Title",
// CreatedAt is zero - should NOT overwrite the stored value
}
Expect(mr.Put(&updatedFile)).To(Succeed())
retrieved, err := mr.Get(fileID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.Title).To(Equal("Updated Title"))
// CreatedAt should still be the original time (not reset)
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(fileID)
})
})
It("checks existence of mediafiles in the DB", func() {
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
Expect(mr.Exists("666")).To(BeFalse())
@@ -465,7 +527,7 @@ var _ = Describe("MediaRepository", func() {
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
results, err := mr.Search("Antenna", 0, 10)
results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results {
@@ -474,7 +536,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media files case insensitively", func() {
results, err := mr.Search("antenna", 0, 10)
results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, result := range results {
@@ -483,7 +545,7 @@ var _ = Describe("MediaRepository", func() {
})
It("returns empty result when no matches found", func() {
results, err := mr.Search("nonexistent", 0, 10)
results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -516,7 +578,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media file by mbz_recording_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -524,7 +586,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media file by mbz_release_track_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -532,7 +594,7 @@ var _ = Describe("MediaRepository", func() {
})
It("returns empty result when MBID is not found", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -552,7 +614,7 @@ var _ = Describe("MediaRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Search never returns missing media files (hardcoded behavior)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())

View File

@@ -56,12 +56,22 @@ func al(al model.Album) model.Album {
return al
}
func alWithTags(a model.Album, tags model.Tags) model.Album {
a = al(a)
a.Tags = tags
return a
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
testArtists = model.Artists{
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
artistCJK,
artistPunctuation,
}
)
@@ -70,11 +80,18 @@ var (
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
testAlbums = model.Albums{
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
albumMultiDisc,
albumCJK,
albumWithVersion,
albumPunctuation,
}
)
@@ -101,6 +118,9 @@ var (
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -112,6 +132,9 @@ var (
songDisc1Track01,
songDisc2Track01,
songDisc1Track02,
songCJK,
songVersioned,
songPunctuation,
}
)

View File

@@ -96,16 +96,6 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
}
func (r *playlistRepository) Delete(id string) error {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
pls, err := r.Get(id)
if err != nil {
return err
}
if pls.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
}
return r.delete(And{Eq{"id": id}, r.userFilter()})
}
@@ -113,14 +103,6 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
pls := dbPlaylist{Playlist: *p}
if pls.ID == "" {
pls.CreatedAt = time.Now()
} else {
ok, err := r.Exists(pls.ID)
if err != nil {
return err
}
if !ok {
return model.ErrNotAuthorized
}
}
pls.UpdatedAt = time.Now()
@@ -132,7 +114,6 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
if p.IsSmartPlaylist() {
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
//r.refreshSmartPlaylist(p)
return nil
}
// Only update tracks if they were specified
@@ -260,10 +241,25 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
}
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on (" +
"annotation.item_id = media_file.id" +
" AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + usr.ID + "')")
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rules.RequiredJoins()
if requiredJoins.Has(criteria.JoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", usr.ID)
}
if requiredJoins.Has(criteria.JoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", usr.ID)
}
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
@@ -320,10 +316,6 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
}
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
if !r.isWritable(playlistId) {
return rest.ErrPermissionDenied
}
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
_, err := r.executeSQL(del)
@@ -439,8 +431,7 @@ func (r *playlistRepository) NewInstance() any {
func (r *playlistRepository) Save(entity any) (string, error) {
pls := entity.(*model.Playlist)
pls.OwnerID = loggedUser(r.ctx).ID
pls.ID = "" // Make sure we don't override an existing playlist
pls.ID = "" // Force new creation
err := r.Put(pls)
if err != nil {
return "", err
@@ -450,24 +441,9 @@ func (r *playlistRepository) Save(entity any) (string, error) {
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
current, err := r.Get(id)
if err != nil {
return err
}
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
// Only the owner can update the playlist
if current.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
// Regular users can't change the ownership of a playlist
if pls.OwnerID != "" && pls.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
}
pls.ID = id
pls.UpdatedAt = time.Now()
_, err = r.put(id, pls, append(cols, "updatedAt")...)
_, err := r.put(id, pls, append(cols, "updatedAt")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@@ -507,23 +483,31 @@ func (r *playlistRepository) removeOrphans() error {
return nil
}
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
func (r *playlistRepository) renumber(id string) error {
var ids []string
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
err := r.queryAllSlice(sq, &ids)
// Step 1: Negate all IDs to clear the positive ID space
_, err := r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
if err != nil {
return err
}
return r.updatePlaylist(id, ids)
}
func (r *playlistRepository) isWritable(playlistId string) bool {
usr := loggedUser(r.ctx)
if usr.IsAdmin {
return true
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
// ORDER BY id DESC restores original order since IDs are now negative.
_, err = r.executeSQL(Expr(
`WITH new_ids AS (
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
FROM playlist_tracks WHERE playlist_id = ?
)
UPDATE playlist_tracks SET id = new_ids.new_id
FROM new_ids
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
if err != nil {
return err
}
pls, err := r.Get(playlistId)
return err == nil && pls.OwnerID == usr.ID
return r.refreshCounters(&model.Playlist{ID: id})
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)

View File

@@ -287,6 +287,106 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("matches tracks from starred albums using albumLoved", func() {
// albumRadioactivity (ID "103") is starred in test fixtures
// Songs in album 103: 1003, 1004, 1005, 1006
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
})
It("matches tracks from starred artists using artistLoved", func() {
// artistBeatles (ID "3") is starred in test fixtures
// Songs with ArtistID "3": 1001, 1002, 3002
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
})
It("matches tracks with combined album and artist criteria", func() {
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
// artistLoved=true → songs with artist 3 (1001, 1002)
// Using Any: union of both sets
rules := &criteria.Criteria{
Expression: criteria.Any{
criteria.Is{"albumLoved": true},
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
})
It("returns no tracks when no albums/artists match", func() {
// No album has rating 5 in fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumRating": 5},
},
}
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
@@ -401,6 +501,79 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Track Deletion and Renumbering", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
// helper to get track positions and media file IDs
getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
pls, err := repo.GetWithTracks(playlistID, false, false)
Expect(err).ToNot(HaveOccurred())
for _, t := range pls.Tracks {
ids = append(ids, t.ID)
mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
}
return
}
It("renumbers correctly after deleting a track from the middle", func() {
By("creating a playlist with 4 tracks")
newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the second track (position 2)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("2")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
})
It("renumbers correctly after deleting the first track", func() {
By("creating a playlist with 3 tracks")
newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the first track (position 1)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("1")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2"}))
Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
})
It("renumbers correctly after deleting the last track", func() {
By("creating a playlist with 3 tracks")
newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the last track (position 3)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("3")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2"}))
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
})
})
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string

View File

@@ -140,15 +140,7 @@ func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}
func (r *playlistTrackRepository) isTracksEditable() bool {
return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
}
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
if !r.isTracksEditable() {
return 0, rest.ErrPermissionDenied
}
if len(mediaFileIds) > 0 {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
} else {
@@ -196,22 +188,7 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
return r.addMediaFileIds(clauses)
}
// Get ids from all current tracks
func (r *playlistTrackRepository) getTracks() ([]string, error) {
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
var ids []string
err := r.queryAllSlice(all, &ids)
if err != nil {
log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return nil, err
}
return ids, nil
}
func (r *playlistTrackRepository) Delete(ids ...string) error {
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
}
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
if err != nil {
return err
@@ -221,9 +198,6 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
}
func (r *playlistTrackRepository) DeleteAll() error {
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"playlist_id": r.playlistId})
if err != nil {
return err
@@ -232,16 +206,45 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
if pos == newPos {
return nil
}
ids, err := r.getTracks()
pid := r.playlistId
// Step 1: Move the source track out of the way (temporary sentinel value)
_, err := r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
if err != nil {
return err
}
newOrder := slice.Move(ids, pos-1, newPos-1)
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
if pos < newPos {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
pid, pos, newPos))
} else {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
pid, newPos, pos))
}
if err != nil {
return err
}
// Step 3: Flip the shifted range back to positive
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
if err != nil {
return err
}
// Step 4: Place the source track at its new position
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
return err
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)

View File

@@ -109,11 +109,10 @@ func booleanFilter(field string, value any) Sqlizer {
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer {
v := strings.ToLower(value.(string))
cond := cmp.Or(
return cmp.Or[Sqlizer](
mbidExpr(tableName, v, mbidFields...),
fullTextExpr(tableName, v),
getSearchStrategy(tableName, v),
)
return cond
}
}

View File

@@ -26,7 +26,9 @@ var _ = Describe("sqlRestful", func() {
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
})
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
@@ -77,6 +79,7 @@ var _ = Describe("sqlRestful", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
tableName = "test_table"
mbidFields = []string{"mbid", "artist_mbid"}
filter = fullTextFilter(tableName, mbidFields...)
@@ -99,11 +102,11 @@ var _ = Describe("sqlRestful", func() {
uuid := "550e8400-e29b-41d4-a716-446655440000"
result := noMbidFilter("search", uuid)
// mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
}
Expect(result).To(Equal(expected))
// mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
})
})
@@ -111,54 +114,75 @@ var _ = Describe("sqlRestful", func() {
It("returns full text search condition only", func() {
result := filter("search", "beatles")
// mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% beatles%"},
}
Expect(result).To(Equal(expected))
// mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% beatles%"))
})
It("handles multi-word search terms", func() {
result := filter("search", "the beatles abbey road")
// Should return And condition directly
andCondition, ok := result.(squirrel.And)
Expect(ok).To(BeTrue())
Expect(andCondition).To(HaveLen(4))
// Check that all words are present (order may vary)
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
// All words should be present as LIKE conditions
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(4))
Expect(args).To(ContainElement("% the%"))
Expect(args).To(ContainElement("% beatles%"))
Expect(args).To(ContainElement("% abbey%"))
Expect(args).To(ContainElement("% road%"))
})
})
Context("when SearchFullString config changes behavior", func() {
It("uses different separator with SearchFullString=false", func() {
conf.Server.SearchFullString = false
conf.Server.Search.FullString = false
result := filter("search", "test query")
andCondition, ok := result.(squirrel.And)
Expect(ok).To(BeTrue())
Expect(andCondition).To(HaveLen(2))
// Check that all words are present with leading space (order may vary)
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
Expect(args).To(ContainElement("% test%"))
Expect(args).To(ContainElement("% query%"))
})
It("uses no separator with SearchFullString=true", func() {
conf.Server.SearchFullString = true
conf.Server.Search.FullString = true
result := filter("search", "test query")
andCondition, ok := result.(squirrel.And)
Expect(ok).To(BeTrue())
Expect(andCondition).To(HaveLen(2))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(HaveLen(2))
Expect(args).To(ContainElement("%test%"))
Expect(args).To(ContainElement("%query%"))
})
})
// Check that all words are present without leading space (order may vary)
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
Context("single-character queries (regression: must not be rejected)", func() {
It("returns valid filter for single-char query with legacy backend", func() {
conf.Server.Search.Backend = "legacy"
result := filter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).ToNot(BeEmpty())
})
It("returns valid filter for single-char query with FTS backend", func() {
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
ftsFilter := fullTextFilter(tableName, mbidFields...)
result := ftsFilter("search", "a")
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
Expect(args).ToNot(BeEmpty())
})
})
@@ -176,10 +200,10 @@ var _ = Describe("sqlRestful", func() {
It("handles special characters that are sanitized", func() {
result := filter("search", "don't")
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
}
Expect(result).To(Equal(expected))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% dont%"))
})
It("returns nil for single quote (SQL injection protection)", func() {
@@ -203,31 +227,30 @@ var _ = Describe("sqlRestful", func() {
result := filter("search", "550e8400-invalid-uuid")
// Should return full text filter since UUID is invalid
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
}
Expect(result).To(Equal(expected))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
})
It("handles empty mbid fields array", func() {
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
result := emptyMbidFilter("search", "test")
// mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% test%"},
}
Expect(result).To(Equal(expected))
// mbidExpr with empty fields returns nil, so search strategy result is returned directly
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% test%"))
})
It("converts value to lowercase before processing", func() {
result := filter("search", "TEST")
// The function converts to lowercase internally
expected := squirrel.And{
squirrel.Like{"test_table.full_text": "% test%"},
}
Expect(result).To(Equal(expected))
sql, args, err := result.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
Expect(args).To(ContainElement("% test%"))
})
})
})

View File

@@ -15,36 +15,71 @@ func formatFullText(text ...string) string {
return " " + fullText
}
// doSearch performs a full-text search with the specified parameters.
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
// searchConfig holds per-repository constants for doSearch.
type searchConfig struct {
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
MBIDFields []string // columns to match when query is a UUID
// LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
// Needed when library access requires a junction table (e.g. artist → library_artist).
LibraryFilter func(sq SelectBuilder) SelectBuilder
}
// searchStrategy defines how to execute a text search against a repository table.
// options carries filters and pagination that must reach all query phases,
// including FTS Phase 1 which builds its own query outside sq.
type searchStrategy interface {
Sqlizer
execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
}
// getSearchStrategy returns the appropriate search strategy based on config and query content.
// Returns nil when the query produces no searchable tokens.
func getSearchStrategy(tableName, query string) searchStrategy {
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
return newLegacySearch(tableName, query)
}
if containsCJK(query) {
return newLikeSearch(tableName, query)
}
return newFTSSearch(tableName, query)
}
// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
// filters and pagination independently.
func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
q = strings.TrimSpace(q)
q = strings.TrimSuffix(q, "*")
sq = sq.Where(Eq{r.tableName + ".missing": false})
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
if q == "" || q == `""` {
sq = sq.OrderBy(cfg.NaturalOrder)
return r.queryAll(sq, results, options)
}
// MBID search: if query is a valid UUID, search by MBID fields instead
if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
return r.queryAll(sq, results)
}
// Min-length guard: single-character queries are too broad for search3.
// This check lives here (not in the strategies) so that fullTextFilter
// (REST filter path) can still use single-character queries.
if len(q) < 2 {
return nil
}
filter := fullTextExpr(r.tableName, q)
if filter != nil {
sq = sq.Where(filter)
sq = sq.OrderBy(orderBys...)
} else {
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
// If the filter is empty, we sort by the specified natural order.
sq = sq.OrderBy(naturalOrder)
strategy := getSearchStrategy(r.tableName, q)
if strategy == nil {
return nil
}
sq = sq.Where(Eq{r.tableName + ".missing": false})
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
}
func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
sq = sq.Where(Eq{r.tableName + ".missing": false})
return r.queryAll(sq, results)
return strategy.execute(r, sq, results, cfg, options)
}
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
@@ -58,20 +93,3 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
}
return Or(cond)
}
func fullTextExpr(tableName string, s string) Sqlizer {
q := str.SanitizeStrings(s)
if q == "" {
return nil
}
var sep string
if !conf.Server.SearchFullString {
sep = " "
}
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
}
return filters
}

View File

@@ -0,0 +1,422 @@
package persistence
import (
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire
// CJK phrases as single tokens, making token-based search ineffective for CJK content.
func containsCJK(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hangul, r) {
return true
}
}
return false
}
// fts5SpecialChars matches characters that should be stripped from user input.
// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes),
// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61
// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors
// as unbalanced string delimiters.
var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes).
// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM").
var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive).
var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word,
// and returns a space-separated string of words that changed after stripping (deduplicated).
// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC".
func normalizeForFTS(values ...string) string {
seen := make(map[string]struct{})
var result []string
for _, v := range values {
for _, word := range strings.Fields(v) {
stripped := fts5PunctStrip.ReplaceAllString(word, "")
if stripped == "" || stripped == word {
continue
}
lower := strings.ToLower(stripped)
if _, ok := seen[lower]; ok {
continue
}
seen[lower] = struct{}{}
result = append(result, stripped)
}
}
return strings.Join(result, " ")
}
// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
func isSingleUnicodeLetter(token string) bool {
r, size := utf8.DecodeRuneInString(token)
return size == len(token) && size > 0 && unicode.IsLetter(r)
}
// namePunctuation is the set of characters commonly used as separators in artist/album
// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates
// for punctuated-word processing; other special characters (^, :, &) are just stripped.
const namePunctuation = `-/.''`
// processPunctuatedWords handles words with embedded name punctuation before the general
// special-character stripping. For each punctuated word it produces either:
// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M"
// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*)
func processPunctuatedWords(input string, phrases []string) (string, []string) {
words := strings.Fields(input)
var result []string
for _, w := range words {
if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) {
result = append(result, w)
continue
}
concat := fts5PunctStrip.ReplaceAllString(w, "")
if concat == "" || concat == w {
result = append(result, w)
continue
}
subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " "))
if len(subTokens) < 2 {
// Single sub-token after splitting (e.g., N' → N): just use the stripped form
result = append(result, concat)
continue
}
// Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only
if isDottedAbbreviation(w, subTokens) {
phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " ")))
} else {
// Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized
phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat))
}
result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1))
}
return strings.Join(result, " "), phrases
}
// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens
// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC").
func isDottedAbbreviation(w string, subTokens []string) bool {
for _, r := range w {
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' {
return false
}
}
for _, st := range subTokens {
if !isSingleUnicodeLetter(st) {
return false
}
}
return true
}
// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression.
// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators
// (by lowercasing them, since FTS5 operators are case-sensitive) and strips
// special characters to prevent query injection.
func buildFTS5Query(userInput string) string {
q := strings.TrimSpace(userInput)
if q == "" || q == `""` {
return ""
}
var phrases []string
result := q
for {
start := strings.Index(result, `"`)
if start == -1 {
break
}
end := strings.Index(result[start+1:], `"`)
if end == -1 {
// Unmatched quote — remove it
result = result[:start] + result[start+1:]
break
}
end += start + 1
phrase := result[start : end+1] // includes quotes
phrases = append(phrases, phrase)
result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
}
// Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
// AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)
// Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping
result, phrases = processPunctuatedWords(result, phrases)
result = fts5SpecialChars.ReplaceAllString(result, " ")
result = fts5LeadingStar.ReplaceAllString(result, "$1")
tokens := strings.Fields(result)
// Append * to plain tokens for prefix matching (e.g., "love" → "love*").
// Skip tokens that are already wildcarded or are quoted phrase placeholders.
for i, t := range tokens {
if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") {
continue
}
tokens[i] = t + "*"
}
result = strings.Join(tokens, " ")
for i, phrase := range phrases {
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
result = strings.ReplaceAll(result, placeholder, phrase)
}
return result
}
// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
type ftsColumn struct {
Name string
Weight float64
}
// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
// The order MUST match the column order in the FTS5 table definition (see migrations).
// All columns are both searched and ranked. When adding indexed-but-not-searched
// columns in the future, use Weight: 0 to exclude from the search column filter.
var ftsColumnDefs = map[string][]ftsColumn{
"media_file": {
{"title", 10.0},
{"album", 5.0},
{"artist", 3.0},
{"album_artist", 3.0},
{"sort_title", 1.0},
{"sort_album_name", 1.0},
{"sort_artist_name", 1.0},
{"sort_album_artist_name", 1.0},
{"disc_subtitle", 1.0},
{"search_participants", 2.0},
{"search_normalized", 1.0},
},
"album": {
{"name", 10.0},
{"sort_album_name", 1.0},
{"album_artist", 3.0},
{"search_participants", 2.0},
{"discs", 1.0},
{"catalog_num", 1.0},
{"album_version", 1.0},
{"search_normalized", 1.0},
},
"artist": {
{"name", 10.0},
{"sort_artist_name", 1.0},
{"search_normalized", 1.0},
},
}
// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
// to avoid per-query allocations.
var (
ftsColumnFilters = map[string]string{}
ftsBM25Weights = map[string]string{}
)
func init() {
for table, cols := range ftsColumnDefs {
var names []string
weights := make([]string, len(cols))
for i, c := range cols {
if c.Weight > 0 {
names = append(names, c.Name)
}
weights[i] = fmt.Sprintf("%.1f", c.Weight)
}
ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
ftsBM25Weights[table] = strings.Join(weights, ", ")
}
}
// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
type ftsSearch struct {
tableName string
ftsTable string
matchExpr string
rankExpr string
}
// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
func (s *ftsSearch) ToSql() (string, []interface{}, error) {
sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
return sql, []interface{}{s.matchExpr}, nil
}
// execute runs a two-phase FTS5 search:
// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
//
// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
qualifiedOrderBys := []string{s.rankExpr}
for _, ob := range cfg.OrderBy {
if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
qualifiedOrderBys = append(qualifiedOrderBys, qualified)
}
}
// Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
// Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
rowidQuery := Select(s.tableName+".rowid").
From(s.tableName).
Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
Where(Eq{s.tableName + ".missing": false}).
OrderBy(qualifiedOrderBys...)
if options.Max > 0 {
rowidQuery = rowidQuery.Limit(uint64(options.Max))
}
if options.Offset > 0 {
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
}
// Library filter + musicFolderId must be applied here, before pagination.
if cfg.LibraryFilter != nil {
rowidQuery = cfg.LibraryFilter(rowidQuery)
} else {
rowidQuery = r.applyLibraryFilter(rowidQuery)
}
if options.Filters != nil {
rowidQuery = rowidQuery.Where(options.Filters)
}
rowidSQL, rowidArgs, err := rowidQuery.ToSql()
if err != nil {
return fmt.Errorf("building FTS rowid query: %w", err)
}
// Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
// join on the ranked rowid set to hydrate with full columns.
sq = sq.RemoveLimit().RemoveOffset()
rankedSubquery := fmt.Sprintf(
"(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
rowidSQL,
)
sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
sq = sq.OrderBy("_ranked._rn")
return r.queryAll(sq, dest)
}
// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
// complex expressions (function calls, aggregations) that can't be used in Phase 1.
func qualifyOrderBy(tableName, orderBy string) string {
orderBy = strings.TrimSpace(orderBy)
if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
return ""
}
parts := strings.Fields(orderBy)
if !strings.Contains(parts[0], ".") {
parts[0] = tableName + "." + parts[0]
}
return strings.Join(parts, " ")
}
// ftsQueryDegraded returns true when the FTS query lost significant discriminating
// content compared to the original input. This happens when special characters that
// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
func ftsQueryDegraded(original, ftsQuery string) bool {
original = strings.TrimSpace(original)
if original == "" || ftsQuery == "" {
return false
}
// Strip quotes from original for comparison — we want the raw content
stripped := strings.ReplaceAll(original, `"`, "")
// Extract the alphanumeric content from the original query
alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
// If the original is entirely alphanumeric, nothing was stripped — not degraded
if len(alphaNum) == len(stripped) {
return false
}
// Check if all effective FTS tokens are very short (≤2 chars).
// Short tokens with prefix matching are too broad when special chars were stripped.
// For quoted phrases, extract the content and check the tokens inside.
tokens := strings.Fields(ftsQuery)
for _, t := range tokens {
t = strings.TrimSuffix(t, "*")
// Skip internal phrase placeholders
if strings.HasPrefix(t, "\x00") {
return false
}
// For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
// the punctuated word was already handled meaningfully — not degraded.
if strings.HasPrefix(t, "(") {
return false
}
// For quoted phrases, check the tokens inside as FTS5 will tokenize them
if strings.HasPrefix(t, `"`) {
// Extract content between quotes
inner := strings.Trim(t, `"`)
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
for _, it := range strings.Fields(innerAlpha) {
if len(it) > 2 {
return false
}
}
continue
}
if len(t) > 2 {
return false
}
}
return true
}
// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
// tokenization stripped significant content from the query (e.g., "1+" → "1*").
// Returns nil when the query produces no searchable tokens at all.
func newFTSSearch(tableName, query string) searchStrategy {
q := buildFTS5Query(query)
if q == "" || ftsQueryDegraded(query, q) {
// Fallback: try LIKE search with the raw query
cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
if cleaned != "" {
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
return newLikeSearch(tableName, cleaned)
}
return nil
}
ftsTable := tableName + "_fts"
matchExpr := q
if cols, ok := ftsColumnFilters[tableName]; ok {
matchExpr = cols + " : (" + q + ")"
}
rankExpr := ftsTable + ".rank"
if weights, ok := ftsBM25Weights[tableName]; ok {
rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
}
s := &ftsSearch{
tableName: tableName,
ftsTable: ftsTable,
matchExpr: matchExpr,
rankExpr: rankExpr,
}
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
return s
}

View File

@@ -0,0 +1,435 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = DescribeTable("buildFTS5Query",
func(input, expected string) {
Expect(buildFTS5Query(input)).To(Equal(expected))
},
Entry("returns empty string for empty input", "", ""),
Entry("returns empty string for whitespace-only input", " ", ""),
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"),
Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"),
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"),
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`),
Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"),
Entry("collapses multiple spaces", "abbey road", "abbey* road*"),
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"),
Entry("strips standalone *", "*", ""),
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"),
Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`),
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"),
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"),
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`),
Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"),
Entry("does not collapse single standalone letter", "A test", "A* test*"),
Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
Entry("returns empty string for mixed punctuation", "!@#$%^&", ""),
Entry("returns empty string for empty quoted phrase", `""`, ""),
)
var _ = DescribeTable("ftsQueryDegraded",
func(original, ftsQuery string, expected bool) {
Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
},
Entry("not degraded for empty original", "", "1*", false),
Entry("not degraded for empty ftsQuery", "1+", "", false),
Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
)
var _ = DescribeTable("normalizeForFTS",
func(expected string, values ...string) {
Expect(normalizeForFTS(values...)).To(Equal(expected))
},
Entry("strips dots and concatenates", "REM", "R.E.M."),
Entry("strips slash", "ACDC", "AC/DC"),
Entry("strips hyphen", "Aha", "A-ha"),
Entry("skips unchanged words", "", "The Beatles"),
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
)
var _ = DescribeTable("containsCJK",
func(input string, expected bool) {
Expect(containsCJK(input)).To(Equal(expected))
},
Entry("returns false for empty string", "", false),
Entry("returns false for ASCII text", "hello world", false),
Entry("returns false for Latin with diacritics", "Björk début", false),
Entry("detects Chinese characters (Han)", "周杰伦", true),
Entry("detects Japanese Hiragana", "こんにちは", true),
Entry("detects Japanese Katakana", "カタカナ", true),
Entry("detects Korean Hangul", "한국어", true),
Entry("detects CJK mixed with Latin", "best of 周杰伦", true),
Entry("detects single CJK character", "a曲b", true),
)
var _ = DescribeTable("qualifyOrderBy",
func(tableName, orderBy, expected string) {
Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
},
Entry("returns empty string for empty input", "artist", "", ""),
Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
Entry("returns empty for expression with comma", "artist", "a, b", ""),
Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
)
var _ = Describe("ftsColumnDefs helpers", func() {
Describe("ftsColumnFilters", func() {
It("returns column filter for media_file", func() {
Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
"{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
))
})
It("returns column filter for album", func() {
Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
"{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
))
})
It("returns column filter for artist", func() {
Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
"{name sort_artist_name search_normalized}",
))
})
It("has no entry for unknown table", func() {
Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
})
})
Describe("ftsBM25Weights", func() {
It("returns weight CSV for media_file", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
"10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
))
})
It("returns weight CSV for album", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
"10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
))
})
It("returns weight CSV for artist", func() {
Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
"10.0, 1.0, 1.0",
))
})
It("has no entry for unknown table", func() {
Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
})
})
It("has definitions for all known tables", func() {
for _, table := range []string{"media_file", "album", "artist"} {
Expect(ftsColumnDefs).To(HaveKey(table))
Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
}
})
It("has matching column count between filter and weights", func() {
for table, cols := range ftsColumnDefs {
// Column filter only includes Weight > 0 columns
filterCount := 0
for _, c := range cols {
if c.Weight > 0 {
filterCount++
}
}
// For now, all columns have Weight > 0, so filter count == total count
Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
}
})
})
var _ = Describe("newFTSSearch", func() {
It("returns nil for empty query", func() {
Expect(newFTSSearch("media_file", "")).To(BeNil())
})
It("returns non-nil for single-character query", func() {
strategy := newFTSSearch("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
})
It("returns ftsSearch with correct table names and MATCH expression", func() {
strategy := newFTSSearch("media_file", "beatles")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.tableName).To(Equal("media_file"))
Expect(fts.ftsTable).To(Equal("media_file_fts"))
Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
})
It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
strategy := newFTSSearch("media_file", "beatles")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.rowid IN"))
Expect(sql).To(ContainSubstring("media_file_fts"))
Expect(sql).To(ContainSubstring("MATCH"))
Expect(args).To(HaveLen(1))
})
It("generates correct FTS table name per entity", func() {
for _, table := range []string{"media_file", "album", "artist"} {
strategy := newFTSSearch(table, "test")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.tableName).To(Equal(table))
Expect(fts.ftsTable).To(Equal(table + "_fts"))
}
})
It("builds bm25() rank expression with column weights", func() {
strategy := newFTSSearch("media_file", "beatles")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
Expect(fts.rankExpr).To(ContainSubstring("10.0"))
strategy = newFTSSearch("artist", "beatles")
fts, ok = strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
})
It("falls back to ftsTable.rank for unknown tables", func() {
strategy := newFTSSearch("unknown_table", "test")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
})
It("wraps query with column filter for known tables", func() {
strategy := newFTSSearch("artist", "Beatles")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
})
It("passes query without column filter for unknown tables", func() {
strategy := newFTSSearch("unknown_table", "test")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.matchExpr).To(Equal("test*"))
})
It("preserves phrase queries inside column filter", func() {
strategy := newFTSSearch("media_file", `"the beatles"`)
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
})
It("preserves prefix queries inside column filter", func() {
strategy := newFTSSearch("media_file", "beat*")
fts, ok := strategy.(*ftsSearch)
Expect(ok).To(BeTrue())
Expect(fts.matchExpr).To(ContainSubstring("beat*"))
})
It("falls back to LIKE search for punctuation-only query", func() {
strategy := newFTSSearch("media_file", "!!!!!!!")
Expect(strategy).ToNot(BeNil())
_, ok := strategy.(*ftsSearch)
Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).To(ContainElement("%!!!!!!!%"))
})
It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
strategy := newFTSSearch("album", "1+")
Expect(strategy).ToNot(BeNil())
_, ok := strategy.(*ftsSearch)
Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
sql, args, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
Expect(args).To(ContainElement("%1+%"))
})
It("returns nil for empty string even with LIKE fallback", func() {
Expect(newFTSSearch("media_file", "")).To(BeNil())
Expect(newFTSSearch("media_file", " ")).To(BeNil())
})
It("returns nil for empty quoted phrase", func() {
Expect(newFTSSearch("media_file", `""`)).To(BeNil())
})
})
var _ = Describe("FTS5 Integration Search", func() {
var (
mr model.MediaFileRepository
alr model.AlbumRepository
arr model.ArtistRepository
)
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
conn := GetDBXBuilder()
mr = NewMediaFileRepository(ctx, conn)
alr = NewAlbumRepository(ctx, conn)
arr = NewArtistRepository(ctx, conn)
})
Describe("MediaFile search", func() {
It("finds media files by title", func() {
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("Radioactivity"))
Expect(results[0].ID).To(Equal(songRadioactivity.ID))
})
It("finds media files by artist name", func() {
results, err := mr.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, r := range results {
Expect(r.Artist).To(Equal("The Beatles"))
}
})
})
Describe("Album search", func() {
It("finds albums by name", func() {
results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("Sgt Peppers"))
Expect(results[0].ID).To(Equal(albumSgtPeppers.ID))
})
It("finds albums with multi-word search", func() {
results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
})
})
Describe("Artist search", func() {
It("finds artists by name", func() {
results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("Kraftwerk"))
Expect(results[0].ID).To(Equal(artistKraftwerk.ID))
})
})
Describe("CJK search", func() {
It("finds media files by CJK title", func() {
results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
Expect(results[0].ID).To(Equal(songCJK.ID))
})
It("finds media files by CJK artist name", func() {
results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Artist).To(Equal("シートベルツ"))
})
It("finds albums by CJK artist name", func() {
results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
Expect(results[0].ID).To(Equal(albumCJK.ID))
})
It("finds artists by CJK name", func() {
results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("シートベルツ"))
Expect(results[0].ID).To(Equal(artistCJK.ID))
})
})
Describe("Album version search", func() {
It("finds albums by version tag via FTS", func() {
results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal(albumWithVersion.ID))
})
})
Describe("Punctuation-only search", func() {
It("finds media files with punctuation-only title", func() {
results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("!!!!!!!"))
Expect(results[0].ID).To(Equal(songPunctuation.ID))
})
})
Describe("Single-character search (doSearch min-length guard)", func() {
It("returns empty results for single-char query via Search", func() {
results, err := mr.Search("a", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
})
})
Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() {
It("returns results with Max=0", func() {
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
})
})
})

View File

@@ -0,0 +1,106 @@
package persistence
import (
"strings"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
// likeSearch implements searchStrategy using LIKE-based SQL filters.
// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
type likeSearch struct {
filter Sqlizer
}
func (s *likeSearch) ToSql() (string, []interface{}, error) {
return s.filter.ToSql()
}
func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
sq = sq.Where(s.filter)
sq = sq.OrderBy(cfg.OrderBy...)
return r.queryAll(sq, dest, options)
}
// newLegacySearch creates a LIKE search against the full_text column.
// Returns nil when the query produces no searchable tokens.
func newLegacySearch(tableName, query string) searchStrategy {
filter := legacySearchExpr(tableName, query)
if filter == nil {
return nil
}
return &likeSearch{filter: filter}
}
// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
// No minimum length is enforced, since single CJK characters are meaningful words.
// Returns nil when the query produces no searchable tokens.
func newLikeSearch(tableName, query string) searchStrategy {
filter := likeSearchExpr(tableName, query)
if filter == nil {
return nil
}
return &likeSearch{filter: filter}
}
// legacySearchExpr generates LIKE-based search filters against the full_text column.
// This is the original search implementation, used when Search.Backend="legacy".
func legacySearchExpr(tableName string, s string) Sqlizer {
q := str.SanitizeStrings(s)
if q == "" {
log.Trace("Search using legacy backend, query is empty", "table", tableName)
return nil
}
var sep string
if !conf.Server.Search.FullString {
sep = " "
}
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
}
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
return filters
}
// likeSearchColumns defines the core columns to search with LIKE queries.
// These are the primary user-visible fields for each entity type.
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
var likeSearchColumns = map[string][]string{
"media_file": {"title", "album", "artist", "album_artist"},
"album": {"name", "album_artist"},
"artist": {"name"},
}
// likeSearchExpr generates LIKE-based search filters against core columns.
// Each word in the query must match at least one column (AND between words),
// and each word can match any column (OR within a word).
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
func likeSearchExpr(tableName string, s string) Sqlizer {
s = strings.TrimSpace(s)
if s == "" {
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
return nil
}
columns, ok := likeSearchColumns[tableName]
if !ok {
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
return nil
}
words := strings.Fields(s)
wordFilters := And{}
for _, word := range words {
colFilters := Or{}
for _, col := range columns {
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
}
wordFilters = append(wordFilters, colFilters)
}
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
return wordFilters
}

View File

@@ -0,0 +1,134 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("newLegacySearch", func() {
It("returns non-nil for single-character query", func() {
strategy := newLegacySearch("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
})
})
var _ = Describe("legacySearchExpr", func() {
It("returns nil for empty query", func() {
Expect(legacySearchExpr("media_file", "")).To(BeNil())
})
It("generates LIKE filter for single word", func() {
expr := legacySearchExpr("media_file", "beatles")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
Expect(args).To(ContainElement("% beatles%"))
})
It("generates AND of LIKE filters for multiple words", func() {
expr := legacySearchExpr("media_file", "abbey road")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("AND"))
Expect(args).To(HaveLen(2))
})
})
var _ = Describe("likeSearchExpr", func() {
It("returns nil for empty query", func() {
Expect(likeSearchExpr("media_file", "")).To(BeNil())
})
It("returns nil for whitespace-only query", func() {
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
})
It("generates LIKE filters against core columns for single CJK word", func() {
expr := likeSearchExpr("media_file", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
// Should have OR between columns for the single word
Expect(sql).To(ContainSubstring("OR"))
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
Expect(args).To(HaveLen(4))
for _, arg := range args {
Expect(arg).To(Equal("%周杰伦%"))
}
})
It("generates AND of OR groups for multi-word query", func() {
expr := likeSearchExpr("media_file", "周杰伦 greatest")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
// Two groups AND'd together, each with 4 columns OR'd
Expect(sql).To(ContainSubstring("AND"))
Expect(args).To(HaveLen(8))
})
It("uses correct columns for album table", func() {
expr := likeSearchExpr("album", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("album.name LIKE"))
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
Expect(args).To(HaveLen(2))
})
It("uses correct columns for artist table", func() {
expr := likeSearchExpr("artist", "周杰伦")
sql, args, err := expr.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("artist.name LIKE"))
Expect(args).To(HaveLen(1))
})
It("returns nil for unknown table", func() {
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
})
})
var _ = Describe("Legacy Integration Search", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
conn := GetDBXBuilder()
mr = NewMediaFileRepository(ctx, conn)
})
It("returns results using legacy LIKE-based search", func() {
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Title).To(Equal("Radioactivity"))
})
It("returns empty results for single-char query (doSearch min-length guard)", func() {
results, err := mr.Search("a", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
})
It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() {
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
Expect(err).ToNot(HaveOccurred())
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
})
})

View File

@@ -1,6 +1,8 @@
package persistence
import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -11,4 +13,100 @@ var _ = Describe("sqlRepository", func() {
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
})
})
Describe("getSearchStrategy", func() {
It("returns FTS strategy by default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "test")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
})
It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "test")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
})
It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = true
strategy := getSearchStrategy("media_file", "test")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("LIKE"))
})
It("routes CJK queries to LIKE strategy instead of FTS", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "周杰伦")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
// CJK should use LIKE, not MATCH
Expect(sql).To(ContainSubstring("LIKE"))
Expect(sql).NotTo(ContainSubstring("MATCH"))
})
It("routes non-CJK queries to FTS strategy", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "beatles")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("MATCH"))
})
It("returns non-nil for single-character query (no min-length in strategy)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "fts"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
})
It("returns non-nil for single-character query with legacy backend", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "a")
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
})
It("uses legacy for CJK when SearchBackend is legacy", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Search.Backend = "legacy"
conf.Server.Search.FullString = false
strategy := getSearchStrategy("media_file", "周杰伦")
Expect(strategy).ToNot(BeNil())
sql, _, err := strategy.ToSql()
Expect(err).ToNot(HaveOccurred())
// Legacy should still use full_text column LIKE
Expect(sql).To(ContainSubstring("LIKE"))
Expect(sql).To(ContainSubstring("full_text"))
})
})
})

View File

@@ -1 +1 @@
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo .
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5,sqlite_spellfix .

View File

@@ -333,76 +333,76 @@
},
"plugin": {
"name": "Plugin |||| Plugins",
"actions": {
"addConfig": "Tilføj konfiguration",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"enable": "Aktivér",
"rescan": "Genskan"
},
"fields": {
"allLibraries": "Tillad alle biblioteker",
"allUsers": "Tillad alle brugere",
"id": "ID",
"name": "Navn",
"description": "Beskrivelse",
"version": "Version",
"author": "Forfatter",
"website": "Hjemmeside",
"permissions": "Tilladelser",
"enabled": "Aktiveret",
"status": "Status",
"path": "Sti",
"lastError": "Fejl",
"hasError": "Fejl",
"updatedAt": "Opdateret",
"createdAt": "Installeret",
"configKey": "Nøgle",
"configValue": "Værdi",
"createdAt": "Installeret",
"description": "Beskrivelse",
"enabled": "Aktiveret",
"hasError": "Fejl",
"id": "ID",
"lastError": "Fejl",
"name": "Navn",
"path": "Sti",
"permissions": "Tilladelser",
"selectedLibraries": "Valgte biblioteker",
"allUsers": "Tillad alle brugere",
"selectedUsers": "Valgte brugere",
"status": "Status",
"updatedAt": "Opdateret",
"version": "Version",
"website": "Hjemmeside"
"allLibraries": "Tillad alle biblioteker",
"selectedLibraries": "Valgte biblioteker"
},
"messages": {
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"noConfig": "Ingen konfiguration angivet",
"noLibraries": "Ingen biblioteker valgt",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"requiredHosts": "Påkrævede værter",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'."
"sections": {
"status": "Status",
"info": "Pluginoplysninger",
"configuration": "Konfiguration",
"manifest": "Manifest",
"usersPermission": "Brugertilladelse",
"libraryPermission": "Bibliotekstilladelse"
},
"status": {
"enabled": "Aktiveret",
"disabled": "Deaktiveret"
},
"actions": {
"enable": "Aktivér",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"addConfig": "Tilføj konfiguration",
"rescan": "Genskan"
},
"notifications": {
"disabled": "Plugin deaktiveret",
"enabled": "Plugin aktiveret",
"error": "Fejl ved opdatering af plugin",
"updated": "Plugin opdateret"
"disabled": "Plugin deaktiveret",
"updated": "Plugin opdateret",
"error": "Fejl ved opdatering af plugin"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
},
"messages": {
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"noConfig": "Ingen konfiguration angivet",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"noLibraries": "Ingen biblioteker valgt",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"requiredHosts": "Påkrævede hosts",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
},
"placeholders": {
"configKey": "nøgle",
"configValue": "værdi"
},
"sections": {
"configuration": "Konfiguration",
"info": "Pluginoplysninger",
"libraryPermission": "Bibliotekstilladelse",
"manifest": "Manifest",
"status": "Status",
"usersPermission": "Brugertilladelse"
},
"status": {
"disabled": "Deaktiveret",
"enabled": "Aktiveret"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
}
}
},
@@ -674,7 +674,8 @@
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
"exportFailed": "Kunne ikke kopiere konfigurationen",
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
"downloadToml": ""
}
},
"activity": {

View File

@@ -2,7 +2,7 @@
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abestiak",
"name": "Abestia |||| Abesti",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
@@ -10,6 +10,7 @@
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"composer": "Konpositorea",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
@@ -33,9 +34,9 @@
"grouping": "Multzokatzea",
"mood": "Aldartea",
"participants": "Partaide gehiago",
"tags": "Traola gehiago",
"mappedTags": "Esleitutako traolak",
"rawTags": "Traola gordinak",
"tags": "Etiketa gehiago",
"mappedTags": "Esleitutako etiketak",
"rawTags": "Etiketa gordinak",
"missing": "Ez da aurkitu"
},
"actions": {
@@ -46,11 +47,12 @@
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Erakutsi informazioa"
"info": "Erakutsi informazioa",
"instantMix": "Berehalako nahastea"
}
},
"album": {
"name": "Albuma |||| Albumak",
"name": "Albuma |||| Album",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
@@ -66,7 +68,7 @@
"date": "Recording Date",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data",
"releases": "Argitaratzea |||| Argitaratzeak",
"releases": "Argitaratzea |||| Argitaratze",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
@@ -101,7 +103,7 @@
}
},
"artist": {
"name": "Artista |||| Artistak",
"name": "Artista |||| Artista",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
@@ -330,6 +332,80 @@
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
},
"plugin": {
"name": "Plugina |||| Plugin",
"fields": {
"id": "IDa",
"name": "Izena",
"description": "Deskribapena",
"version": "Bertsioa",
"author": "Autorea",
"website": "Webgunea",
"permissions": "Baimenak",
"enabled": "Gaituta",
"status": "Egoera",
"path": "Bidea",
"lastError": "Errorea",
"hasError": "Errorea",
"updatedAt": "Eguneratuta",
"createdAt": "Instalatuta",
"configKey": "Gakoa",
"configValue": "Balioa",
"allUsers": "Baimendu erabiltzaile guztiak",
"selectedUsers": "Hautatutako erabiltzaileak",
"allLibraries": "Baimendu liburutegi guztiak",
"selectedLibraries": "Hautatutako liburutegiak"
},
"sections": {
"status": "Egoera",
"info": "Pluginaren informazioa",
"configuration": "Konfigurazioa",
"manifest": "Manifestua",
"usersPermission": "Erabiltzaileen baimenak",
"libraryPermission": "Liburutegien baimenak"
},
"status": {
"enabled": "Gaituta",
"disabled": "Ezgaituta"
},
"actions": {
"enable": "Gaitu",
"disable": "Ezgaitu",
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
"addConfig": "Gehitu konfigurazioa",
"rescan": "Arakatu berriro"
},
"notifications": {
"enabled": "Plugina gaituta",
"disabled": "Plugina ezgaituta",
"updated": "Plugina eguneratuta",
"error": "Errorea plugina eguneratzean"
},
"validation": {
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
},
"messages": {
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
"noConfig": "Ez da konfiguraziorik ezarri",
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
"noUsers": "Ez da erabiltzailerik hautatu",
"permissionReason": "Arrazoia",
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
"noLibraries": "Ez da liburutegirik hautatu",
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
"requiredHosts": "Beharrezko ostatatzaileak"
},
"placeholders": {
"configKey": "gakoa",
"configValue": "balioa"
}
}
},
"ra": {
@@ -483,6 +559,7 @@
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
"startingInstantMix": "Berehalako nahastea kargatzen…",
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",

View File

@@ -10,6 +10,7 @@
"playCount": "Lejátszások",
"title": "Cím",
"artist": "Előadó",
"composer": "Zeneszerző",
"album": "Album",
"path": "Elérési út",
"libraryName": "Könyvtár",
@@ -46,7 +47,8 @@
"shuffleAll": "Keverés",
"download": "Letöltés",
"playNext": "Lejátszás következőként",
"info": "Részletek"
"info": "Részletek",
"instantMix": "Instant keverés"
}
},
"album": {
@@ -325,6 +327,80 @@
"scanInProgress": "Szkennelés folyamatban...",
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
}
},
"plugin": {
"name": "Kiegészítő |||| Kiegészítők",
"fields": {
"id": "ID",
"name": "Név",
"description": "Leírás",
"version": "Verzió",
"author": "Fejlesztő",
"website": "Weboldal",
"permissions": "Engedélyek",
"enabled": "Engedélyezve",
"status": "Státusz",
"path": "Útvonal",
"lastError": "Hiba",
"hasError": "Hiba",
"updatedAt": "Frissítve",
"createdAt": "Telepítve",
"configKey": "Kulcs",
"configValue": "Érték",
"allUsers": "Összes felhasználó engedélyezése",
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
"allLibraries": "Összes könyvtár engedélyezése",
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
},
"sections": {
"status": "Státusz",
"info": "Kiegészítő információi",
"configuration": "Konfiguráció",
"manifest": "Manifest",
"usersPermission": "Felhasználói engedélyek",
"libraryPermission": "Könyvtári engedélyek"
},
"status": {
"enabled": "Engedélyezve",
"disabled": "Letiltva"
},
"actions": {
"enable": "Engedélyezés",
"disable": "Letiltás",
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
"disabledUsersRequired": "Válassz felhasználókat",
"disabledLibrariesRequired": "Válassz könyvtárakat",
"addConfig": "Konfiguráció hozzáadása",
"rescan": "Újraszkennelés"
},
"notifications": {
"enabled": "Kiegészítő engedélyezve",
"disabled": "Kiegészítő letiltva",
"updated": "Kiegészítő frissítve",
"error": "Hiba történt a kiegészítő frissítése közben"
},
"validation": {
"invalidJson": "A konfigurációs JSON érvénytelen"
},
"messages": {
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
"configValidationError": "Helytelen konfiguráció:",
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
"clickPermissions": "Kattints egy engedélyre a részletekért",
"noConfig": "Nincs konfiguráció beállítva",
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
"noUsers": "Nincsenek kiválasztott felhasználók",
"permissionReason": "Indok",
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
"noLibraries": "Nincs kiválasztott könyvtár",
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
"requiredHosts": "Szükséges hostok"
},
"placeholders": {
"configKey": "kulcs",
"configValue": "érték"
}
}
},
"ra": {
@@ -402,7 +478,7 @@
"loading": "Betöltés",
"not_found": "Nem található",
"show": "%{name} #%{id}",
"empty": "Nincs %{name} még.",
"empty": "Nincsenek %{name}.",
"invite": "Szeretnél egyet hozzáadni?"
},
"input": {
@@ -478,6 +554,7 @@
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
"startingInstantMix": "Instant keverés töltődik...",
"noTopSongsFound": "Nincsenek top számok",
"noPlaylistsAvailable": "Nem áll rendelkezésre",
"delete_user_title": "Felhasználó törlése '%{name}'",
@@ -591,6 +668,7 @@
"currentValue": "Jelenlegi érték",
"configurationFile": "Konfigurációs fájl",
"exportToml": "Konfiguráció exportálása (TOML)",
"downloadToml": "Konfiguráció letöltése (TOML)",
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
"exportFailed": "Falha ao copiar configuração",
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
"downloadToml": "Baixar configuração (TOML)"
}
},
"activity": {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,10 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -27,7 +27,7 @@ var (
)
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
pls core.Playlists, m metrics.Metrics) model.Scanner {
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
@@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner {
// CallScan starts an in-process scan of specific library/folder pairs.
// If targets is empty, it scans all libraries.
// This is meant to be called from the command line (see cmd/scan.go).
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -98,7 +98,7 @@ type controller struct {
cw artwork.CacheWarmer
broker events.Broker
metrics metrics.Metrics
pls core.Playlists
pls playlists.Playlists
limiter *rate.Sometimes
devExternalScanner bool
count atomic.Uint32

View File

@@ -5,9 +5,9 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -31,7 +31,7 @@ var _ = Describe("Controller", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
})
It("includes last scan error", func() {

View File

@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
for _, target := range targets {
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
os.Remove(tmpFile.Name())
os.Remove(tmpFile.Name()) //nolint:gosec
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}

View File

@@ -10,7 +10,7 @@ import (
"slices"
"time"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/chrono"
)
@@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool {
func (f *folderEntry) toFolder() *model.Folder {
folder := model.NewFolder(f.job.lib, f.path)
folder.NumAudioFiles = len(f.audioFiles)
if core.InPlaylistsPath(*folder) {
if playlists.InPath(*folder) {
folder.NumPlaylists = f.numPlaylists
}
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))

View File

@@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -267,6 +268,10 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
// Preserve the original created_at from the missing file, so moved tracks
// don't appear in "Recently Added"
target.CreatedAt = missing.CreatedAt
// Update the target media file with the missing file's ID. This effectively "moves" the track
// to the new location while keeping its annotations and references intact.
target.ID = missing.ID
@@ -298,6 +303,14 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
// Keep created_at field from previous instance of the album, so moved albums
// don't appear in "Recently Added"
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
p.processedAlbumAnnotations[newAlbumID] = true
}

View File

@@ -724,6 +724,120 @@ var _ = Describe("phaseMissingTracks", func() {
}) // End of Context "with multiple libraries"
})
Describe("CreatedAt preservation (#5050)", func() {
var albumRepo *tests.MockAlbumRepo
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
albumRepo.CopyAttributesCalls = make(map[string]string)
})
It("should preserve the missing track's created_at when moving within a library", func() {
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "1", PID: "A", Path: "old/song.mp3",
AlbumID: "album-1",
LibraryID: 1,
CreatedAt: originalTime,
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
matchedTrack := model.MediaFile{
ID: "2", PID: "A", Path: "new/song.mp3",
AlbumID: "album-1", // Same album
LibraryID: 1,
CreatedAt: time.Now(), // Much newer
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
})
It("should preserve created_at during cross-library moves with album change", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
AlbumID: "old-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
AlbumID: "new-album",
LibraryID: 2,
CreatedAt: time.Now(),
}
// Set up albums so CopyAttributes can find them
albumRepo.SetData(model.Albums{
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
})
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should be preserved from the missing file
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// Album's created_at should be copied from old to new
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
// Verify the new album's CreatedAt was actually updated
newAlbum, err := albumRepo.Get("new-album")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
})
It("should not copy album created_at when album ID does not change", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
AlbumID: "same-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
AlbumID: "same-album", // Same album
LibraryID: 1,
CreatedAt: time.Now(),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should still be preserved
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// CopyAttributes should NOT have been called (same album)
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
})
})
Describe("Album Annotation Reassignment", func() {
var (
albumRepo *tests.MockAlbumRepo

View File

@@ -10,8 +10,8 @@ import (
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -21,12 +21,12 @@ type phasePlaylists struct {
ctx context.Context
scanState *scanState
ds model.DataStore
pls core.Playlists
pls playlists.Playlists
cw artwork.CacheWarmer
refreshed atomic.Uint32
}
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
return &phasePlaylists{
ctx: ctx,
scanState: scanState,

View File

@@ -9,8 +9,8 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
@@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() {
type mockPlaylists struct {
mock.Mock
core.Playlists
playlists.Playlists
}
func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {

View File

@@ -11,8 +11,8 @@ import (
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -23,7 +23,7 @@ import (
type scannerImpl struct {
ds model.DataStore
cw artwork.CacheWarmer
pls core.Playlists
pls playlists.Playlists
}
// scanState holds the state of an in-progress scan, to be passed to the various phases

View File

@@ -12,9 +12,9 @@ import (
"github.com/dustin/go-humanize"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) {
ds := persistence.New(db.Db())
conf.Server.DevExternalScanner = false
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
fs := storagetest.FakeFS{}
storagetest.Register("fake", &fs)

View File

@@ -11,9 +11,9 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
// Create two test libraries (let DB auto-assign IDs)
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}

View File

@@ -8,9 +8,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())

View File

@@ -11,9 +11,9 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())

View File

@@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
}
c := http.Client{Timeout: imageRequestTimeout}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
return strings.NewReader(string(defaultImage)), nil

113
server/e2e/doc.go Normal file
View File

@@ -0,0 +1,113 @@
// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API.
//
// These tests exercise the full HTTP request/response cycle through the Subsonic API router,
// using a real SQLite database and real repository implementations while stubbing out external
// services (artwork, streaming, scrobbling, etc.) with noop implementations.
//
// # Test Infrastructure
//
// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked
// through the standard Go test entry point [TestSubsonicE2E], which initializes the test
// environment, creates a temporary SQLite database, and runs the specs.
//
// # Setup and Teardown
//
// During [BeforeSuite], the test infrastructure:
//
// 1. Creates a temporary SQLite database with WAL journal mode.
// 2. Initializes the schema via [db.Init].
// 3. Creates two test users: an admin ("admin") and a regular user ("regular"),
// both with the password "password".
// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem
// (scheme "fake:///music") using the [storagetest] package.
// 5. Populates the filesystem with a set of test tracks spanning multiple artists,
// albums, genres, and years.
// 6. Runs the scanner to import all metadata into the database.
// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation.
//
// # Test Data
//
// The fake filesystem contains the following music library structure:
//
// Rock/The Beatles/Abbey Road/
// 01 - Come Together.mp3 (1969, Rock)
// 02 - Something.mp3 (1969, Rock)
// Rock/The Beatles/Help!/
// 01 - Help.mp3 (1965, Rock)
// Rock/Led Zeppelin/IV/
// 01 - Stairway To Heaven.mp3 (1971, Rock)
// Jazz/Miles Davis/Kind of Blue/
// 01 - So What.mp3 (1959, Jazz)
// Pop/
// 01 - Standalone Track.mp3 (2020, Pop)
//
// # Database Isolation
//
// Before each top-level Describe block, the [setupTestDB] function restores the database
// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all
// table data from the snapshot back into the main database, providing each test group with
// a clean, consistent starting state without the overhead of re-scanning the filesystem.
//
// A fresh [subsonic.Router] is also created for each test group, wired with real data store
// repositories and noop stubs for external services:
//
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
// - noopStreamer: returns [model.ErrNotFound] for all stream requests.
// - noopArchiver: returns [model.ErrNotFound] for all archive requests.
// - noopProvider: returns empty results for all external metadata lookups.
// - noopPlayTracker: silently discards all scrobble events.
//
// # Request Helpers
//
// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API
// request with authentication parameters (username, password, API version "1.16.1", client
// name "test-client", and JSON format). Convenience wrappers include:
//
// - [doReq]: sends a request as the admin user and returns the parsed JSON response.
// - [doReqWithUser]: sends a request as a specific user.
// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for
// binary content or status code inspection.
//
// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope
// and returns the inner response map.
//
// # Test Organization
//
// Each test file covers a logical group of Subsonic API endpoints:
//
// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions
// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory,
// getArtist, getAlbum, getSong, getGenres
// - subsonic_searching_test.go: search2, search3
// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2
// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists,
// updatePlaylist, deletePlaylist
// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble
// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar,
// getLyrics, getLyricsBySongId
// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark,
// savePlayQueue, getPlayQueue
// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation,
// updateInternetRadioStation, deleteInternetRadioStation
// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare
// - subsonic_users_test.go: getUser, getUsers
// - subsonic_scan_test.go: getScanStatus, startScan
// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement
// - subsonic_multilibrary_test.go: multi-library access control and data isolation
//
// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block,
// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist
// and then verifying it can be retrieved).
//
// # Running
//
// The e2e tests are included in the standard test suite and can be run with:
//
// make test PKG=./server/e2e # Run only e2e tests
// make test # Run all tests including e2e
// make test-race # Run with race detector
//
// [Ginkgo]: https://onsi.github.io/ginkgo/
// [Gomega]: https://onsi.github.io/gomega/
// [storagetest]: /core/storage/storagetest
package e2e

View File

@@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
@@ -51,6 +52,17 @@ type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
// MusicBrainz ID constants for test data (valid UUID v4 values)
const (
mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"
mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d"
mbidComeTogether = "11111111-1111-4111-a111-111111111111" // mbz_release_track_id
mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222" // mbz_recording_id
mbidSomething = "33333333-3333-4333-a333-333333333333" // mbz_release_track_id
mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id
)
// Shared test state
var (
ctx context.Context
@@ -69,6 +81,14 @@ var (
Name: "Admin User",
IsAdmin: true,
}
// Regular (non-admin) user for permission tests
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
}
)
func createFS(files fstest.MapFS) storagetest.FakeFS {
@@ -80,24 +100,41 @@ func createFS(files fstest.MapFS) storagetest.FakeFS {
// buildTestFS creates the full test filesystem matching the plan
func buildTestFS() storagetest.FakeFS {
abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
abbeyRoad := template(_t{
"albumartist": "The Beatles",
"artist": "The Beatles",
"album": "Abbey Road",
"year": 1969,
"genre": "Rock",
"musicbrainz_artistid": mbidBeatlesArtist,
"musicbrainz_albumartistid": mbidBeatlesArtist,
"musicbrainz_albumid": mbidAbbeyRoadAlbum,
"musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup,
})
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"})
return createFS(fstest.MapFS{
// Rock / The Beatles / Abbey Road
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")),
// Rock / The Beatles / Help!
// Rock / The Beatles / Abbey Road (with MBIDs)
// Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID),
// "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID).
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})),
// Rock / The Beatles / Help! (no MBIDs)
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
// Rock / Led Zeppelin / IV
// Rock / Led Zeppelin / IV (no MBIDs)
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
// Jazz / Miles Davis / Kind of Blue
// Jazz / Miles Davis / Kind of Blue (no MBIDs)
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
// Pop (standalone track)
// Pop (standalone track, no MBIDs)
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
// CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests)
"CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")),
// _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
})
@@ -288,19 +325,29 @@ var _ = BeforeSuite(func() {
adminUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())
regularUserWithPass := regularUser
regularUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&regularUserWithPass)).To(Succeed())
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
Expect(err).ToNot(HaveOccurred())
adminUser.Libraries = loadedUser.Libraries
loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedRegular.Libraries
ctx = request.WithUser(GinkgoT().Context(), adminUser)
buildTestFS()
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(initDS), metrics.NewNoopInstance())
playlists.NewPlaylists(initDS), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
@@ -334,7 +381,7 @@ func setupTestDB() {
// Create the Subsonic Router with real DS + noop stubs
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
@@ -344,7 +391,7 @@ func setupTestDB() {
noopProvider{},
s,
events.NoopBroker(),
core.NewPlaylists(ds),
playlists.NewPlaylists(ds),
noopPlayTracker{},
core.NewShare(ds),
playback.PlaybackServer(nil),
@@ -363,7 +410,7 @@ func restoreDB() {
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
Expect(err).ToNot(HaveOccurred())
rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
Expect(err).ToNot(HaveOccurred())
var tables []string
for rows.Next() {

View File

@@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5))
Expect(resp.AlbumList.Album).To(HaveLen(6))
})
It("type=alphabeticalByName sorts albums by name", func() {
@@ -27,13 +27,14 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5))
// Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop
Expect(albums).To(HaveLen(6))
// Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop
Expect(albums[0].Title).To(Equal("Abbey Road"))
Expect(albums[1].Title).To(Equal("Help!"))
Expect(albums[2].Title).To(Equal("IV"))
Expect(albums[3].Title).To(Equal("Kind of Blue"))
Expect(albums[4].Title).To(Equal("Pop"))
Expect(albums[1].Title).To(Equal("COWBOY BEBOP"))
Expect(albums[2].Title).To(Equal("Help!"))
Expect(albums[3].Title).To(Equal("IV"))
Expect(albums[4].Title).To(Equal("Kind of Blue"))
Expect(albums[5].Title).To(Equal("Pop"))
})
It("type=alphabeticalByArtist sorts albums by artist name", func() {
@@ -41,29 +42,32 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5))
Expect(albums).To(HaveLen(6))
// Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles"
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ
Expect(albums[0].Artist).To(Equal("The Beatles"))
Expect(albums[1].Artist).To(Equal("The Beatles"))
Expect(albums[2].Artist).To(Equal("Led Zeppelin"))
Expect(albums[3].Artist).To(Equal("Miles Davis"))
Expect(albums[4].Artist).To(Equal("Various"))
Expect(albums[5].Artist).To(Equal("シートベルツ"))
})
It("type=random returns albums", func() {
resp := doReq("getAlbumList", "type", "random")
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5))
Expect(resp.AlbumList.Album).To(HaveLen(6))
})
It("type=byGenre filters by genre parameter", func() {
resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz")
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue"))
Expect(resp.AlbumList.Album).To(HaveLen(2))
for _, a := range resp.AlbumList.Album {
Expect(a.Genre).To(Equal("Jazz"))
}
})
It("type=byYear filters by fromYear/toYear range", func() {
@@ -184,7 +188,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList2).ToNot(BeNil())
albums := resp.AlbumList2.Album
Expect(albums).To(HaveLen(5))
Expect(albums).To(HaveLen(6))
// Verify AlbumID3 format fields
Expect(albums[0].Name).To(Equal("Abbey Road"))
Expect(albums[0].Id).ToNot(BeEmpty())
@@ -195,7 +199,7 @@ var _ = Describe("Album List Endpoints", func() {
resp := doReq("getAlbumList2", "type", "newest")
Expect(resp.AlbumList2).ToNot(BeNil())
Expect(resp.AlbumList2.Album).To(HaveLen(5))
Expect(resp.AlbumList2.Album).To(HaveLen(6))
})
})
@@ -240,7 +244,7 @@ var _ = Describe("Album List Endpoints", func() {
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).ToNot(BeEmpty())
Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6))
Expect(resp.RandomSongs.Songs).To(HaveLen(7))
})
It("respects size parameter", func() {
@@ -254,8 +258,10 @@ var _ = Describe("Album List Endpoints", func() {
resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz")
Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).To(HaveLen(1))
Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz"))
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
for _, s := range resp.RandomSongs.Songs {
Expect(s.Genre).To(Equal("Jazz"))
}
})
})

Some files were not shown because too many files have changed in this diff Show More