Compare commits

..

59 Commits

Author SHA1 Message Date
Deluan
b4b1830513 refactor: remove outdated comments regarding NATURALSORT collation behavior
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 11:35:36 -05:00
Deluan Quintão
eca4c5acf0 Merge branch 'master' into custom-collation-function 2026-02-17 11:27:15 -05:00
Deluan
e766a5d780 refactor: extend NATURALSORT collation to playlist and radio indexes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:42:05 -05:00
Deluan
90d6cd5f47 refactor: update collation handling for natural sorting in SQL queries
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:37:08 -05:00
Deluan
24ab04581a fix format
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:45:34 -05:00
Deluan
8e647a0e41 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:31:37 -05:00
Deluan Quintão
86c326bd4a fix doc
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-17 09:30:18 -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
929e7193b4 refactor: use natural.Compare directly instead of wrapper 2026-02-17 09:00:23 -05:00
Deluan
9bcefea0ca refactor: use maruel/natural for NATURALSORT collation instead of custom impl
Replace the hand-rolled natural sort comparison with a thin wrapper
around github.com/maruel/natural.Compare, which is already a dependency.
The wrapper just lowercases both inputs for case-insensitive comparison.
2026-02-17 09:00:20 -05:00
Deluan
b0cb40b029 feat: add custom NATURALSORT collation for natural number ordering
Register a custom SQLite collation function (NATURALSORT) that compares
strings using natural sort ordering, where embedded numeric sequences are
compared as numbers rather than lexicographically. This fixes the issue
where albums like "Bravo Hits 1-132" sort as 1, 10, 100... instead of
1, 2, 3... 10, 11...

Closes navidrome/navidrome#4891
2026-02-17 09:00:15 -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
Kendall Garner
34c6f12aee feat(server): add explicit status support in smart playlists (#5031)
* feat(smart playlist): add explicit status support

* retrigger checks

* rename field (remove snake_case)

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-02-10 18:22:34 -05:00
Denisa Rissa
eb9ebc3fba fix(ui): add missing keys in Danish translation (#5011)
update Danish translation with 59 missing keys for the `resources.plugin` section as well as `message.startingInstantMix`, `resources.song.actions.instantMix`, `resources.song.fields.composer`, and `resources.plugin.name`.
2026-02-10 14:05:14 -05:00
Deluan
e05a7e230f fix: prevent data race on conf.Server during cleanup in e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-10 11:25:17 -05:00
Rob Emery
62f9c3a458 fix: linux service should restart when upgrading (#5001)
* When upgrading packages this should restart the service

* We need to specify configfile otherwise this command doesn't work
2026-02-09 17:11:45 -05:00
Deluan
fd09ca103f fix(scanner): resolve data race on conf.Server access in getScanner
Captured DevExternalScanner config value in the controller struct at
construction time instead of reading the global conf.Server pointer in
getScanner(). The background goroutine spawned by ScanFolders() was
reading conf.Server.DevExternalScanner concurrently with test cleanup
reassigning the conf.Server pointer, causing a data race detected by
the race detector in the E2E test suite.
2026-02-09 16:42:05 -05:00
Deluan Quintão
ed79a8897b fix(scanner): pass filename hint to gotaglib's OpenStream for format detection (#5012)
* fix: split reflex -R flags to preserve directory exclusion optimization

Combining the _test.go exclusion pattern (which uses $) into the same -R
regex as the directory prefixes (^ui, ^data, ^db/migrations) disabled
reflex's ExcludePrefix optimization. Reflex disables prefix-based
directory skipping when the regex AST contains $, \z, or \b operators,
causing it to traverse into ui/node_modules and hit "too many open files".

Splitting into two separate -R flags fixes this: the directory prefix
regex remains $-free so ExcludePrefix works, while the _test.go pattern
gets its own flag where the $ anchor doesn't affect directory skipping.

* fix(gotaglib): pass filename hint to OpenStream for format detection

OpenStream relies on content-sniffing when no filename is provided,
which fails for some files (e.g. OPUS). Pass the filename via the new
WithFilename option so TagLib can use the file extension as a hint.

Also adds an OPUS test fixture and test entry.

Relates to https://github.com/navidrome/navidrome/issues/4604#issuecomment-3868569113, #4998, #5010
2026-02-09 16:16:28 -05:00
Deluan
302d99aa8b chore(deps): update dependencies in go.mod and go.sum
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:05:37 -05:00
Deluan
bee0305831 fix: split reflex -R flags to preserve directory exclusion optimization
Combining the _test.go exclusion pattern (which uses $) into the same -R
regex as the directory prefixes (^ui, ^data, ^db/migrations) disabled
reflex's ExcludePrefix optimization. Reflex disables prefix-based
directory skipping when the regex AST contains $, \z, or \b operators,
causing it to traverse into ui/node_modules and hit "too many open files".

Splitting into two separate -R flags fixes this: the directory prefix
regex remains $-free so ExcludePrefix works, while the _test.go pattern
gets its own flag where the $ anchor doesn't affect directory skipping.
2026-02-09 10:47:30 -05:00
Deluan
c280dd67a4 refactor: run Go modernize
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 08:44:44 -05:00
Deluan Quintão
8319905d2c test(subsonic): add comprehensive e2e test suite for Subsonic API (#5003)
* test(e2e): add comprehensive tests for Subsonic API endpoints

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

* fix(e2e): improve database handling and snapshot restoration in tests

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

* test(e2e): add tests for album sharing and user isolation scenarios

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

* test(e2e): add tests for multi-library support and user access control

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

* test(e2e): tests are fast, no need to skip on -short

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

* address gemini comments

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

* fix(tests): prevent MockDataStore from caching repos with stale context

When RealDS is set, MockDataStore previously cached repository instances
on first access, binding them to the initial caller's context. This meant
repos created with an admin context would skip library filtering for all
subsequent non-admin calls, silently masking access control bugs. Changed
MockDataStore to delegate to RealDS on every call without caching, so each
caller gets a fresh repo with the correct context. Removed the pre-warm
calls in e2e setupTestDB that were working around the old caching behavior.

* test(e2e): route subsonic tests through full HTTP middleware stack

Replace direct router method calls with full HTTP round-trips via
router.ServeHTTP(w, r) across all 15 e2e test files. Tests now exercise
the complete chi middleware chain including postFormToQueryParams,
checkRequiredParameters, authenticate, UpdateLastAccessMiddleware,
getPlayer, and sendResponse/sendError serialization.

New helpers (doReq, doReqWithUser, doRawReq, buildReq, parseJSONResponse)
use plaintext password auth and JSON response format. Old helpers that
injected context directly (newReq, newReqWithUser, newRawReq) are removed.
Sharing tests now set conf.Server.EnableSharing before router creation to
ensure sharing routes are registered.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 08:24:37 -05:00
Deluan
c80ef8ae41 chore: ignore _test.go files in reflex conf
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 20:06:19 -05:00
Deluan
0a4722802a fix(subsonic): validate JSONP callback parameter
Added validation to ensure the JSONP callback parameter is a valid
JavaScript identifier before reflecting it into the response. Invalid
callbacks now return a JSON error response instead. This prevents
malicious input from being injected into the response body via the
callback parameter.
2026-02-08 10:33:46 -05:00
Maximilian
a704e86ac1 refactor: run Go modernize (#5002) 2026-02-08 09:57:30 -05:00
Deluan
408aa78ed5 fix(scanner): log warning when metadata extraction fails
Added a warning log when the gotaglib extractor fails to read metadata
from a file. Previously, extraction errors were silently skipped, making
it difficult to diagnose issues with unreadable files during scanning.

Ref: https://github.com/navidrome/navidrome/issues/4604#issuecomment-3865690165
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 21:39:07 -05:00
Deluan
29f98b889b chore(deps): update dependencies in go.mod and go.sum to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:23:58 -05:00
Kendall Garner
1e37e680d7 feat(agents): Add artist url and top and similar songs to ListenBrainz agent (#4934)
* feat(agents): Add artist url and top songs to ListenBrainz agent

* add newline at end of file

* respond to some feedback

* add more tests, include more metadata in top songs

* add duration to album info

* add similar artists from labs

* add similar artists and track radio

* fix(client): replace sort with slices.SortFunc for deterministic ordering of recordings with same score

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

* fix: typos

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

* refactor: use struct literal initialization consistently

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

* feat: configurable artist and track algorithms

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

* test configuration changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:20:42 -05:00
Kendall Garner
6fb4cd277e feat(subsonic): add OS readonly and validUntil properties in playlists (#4993)
* feat(subsonic): add OS readonly and validUntil properties

* remove duplicated test

* test: fix and enable disabled child smart playlist tests

Fixed the XContext("child smart playlists") tests that were disabled with
a TODO comment. The tests had several issues: nested playlists were missing
Public: true (required by InPlaylist criteria), the criteria matched no
test fixtures, the "not expired" test set EvaluatedAt on the parent too
(preventing it from refreshing at all), and the "expired" test dereferenced
a nil EvaluatedAt. Added proper cleanup with DeferCleanup and config
restoration via configtest.

* fix(subsonic): always include readonly field in JSON playlist responses

Removed omitempty from the JSON tag of the Readonly field in
OpenSubsonicPlaylist so that readonly: false is always serialized in
JSON responses, per the OpenSubsonic spec requirement that supported
fields must be returned with default values. Added a test case with an
empty OpenSubsonicPlaylist to verify the behavior.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-06 19:35:54 -05:00
Deluan
e11206f0ee fix(lastfm): clean up Last.fm content by removing "Read more" links from descriptions and bios
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-06 16:52:34 -05:00
Deluan Quintão
b4e03673ba fix(scanner): preserve parentheses in lyrics when processing alias tags (#4985)
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-06 16:21:35 -05:00
Deluan
01c839d9be fix: add music.old to .dockerignore and .gitignore 2026-02-06 07:40:05 -05:00
Kendall Garner
2731e25fd2 fix(ui): use div for fragment, check lastfm url for artist page (#4980)
* fix(ui): use div for fragment, check lastfm url for artist page

* use span instead of div for better compat

* fix: implement isLastFmURL utility and add tests for URL validation

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2026-02-04 17:34:26 -05:00
Boris Rorsvort
4f3845bbe3 fix(ui): Nautiline theme font path (#4983)
* fix: Nautiline theme font path

* refactor font path
2026-02-04 17:24:30 -05:00
Deluan Quintão
e8863ed147 feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982)
* feat: implement raw binary framing for host function responses

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

* feat: add CallRaw method for Subsonic API to handle binary responses

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

* test: add tests for raw=true methods and binary framing generation

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

* fix: improve error message for malformed raw responses to indicate incomplete header

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

* fix: add wasm_import_module attribute for raw methods and improve content-type handling

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-04 15:48:08 -05:00
dependabot[bot]
19ea338bed chore(deps): bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 in /ui (#4974)
Bumps @isaacs/brace-expansion from 5.0.0 to 5.0.1.

---
updated-dependencies:
- dependency-name: "@isaacs/brace-expansion"
  dependency-version: 5.0.1
  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-04 10:12:00 -05:00
dependabot[bot]
338853468f chore(deps): bump bytes in /plugins/pdk/rust/nd-pdk-host (#4973)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  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-04 10:11:37 -05:00
Deluan
4e720ee931 fix: handle WASM runtime panics in gotaglib openFile function.
see #4977

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 22:56:47 -05:00
dependabot[bot]
0c8f2a559c chore(deps): bump lodash from 4.17.21 to 4.17.23 in /ui (#4922)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-03 13:12:53 -05:00
Deluan Quintão
a1036e75a9 fix(ui): update Catalan, German, Spanish, French, Indonesian, Polish translations from POEditor (#4960)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-02-03 12:50:16 -05:00
Deluan
2829cec0ce fix(subsonic): add SubMusic to default MinimalClients list
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 12:46:39 -05:00
Deluan
ddff5db14a chore: format JSX components
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 12:46:39 -05:00
Alex Gustafsson
d7ec7355c9 Merge commit from fork
* Rework frontend code interacting directly with DOM

Rework frontend code that uses user-supplied data to render things like
comments and notes. In places where using React's built-in sanitization
is possible, the feature is used. In other places, where some markup
might be necessary, DOMPurify is used to sanitize the HTML before
rendering it.

Solves: GHSA-rh3r-8pxm-hg4w

* Remove test post DOM rework

* fixup! Rework frontend code interacting directly with DOM
2026-02-03 12:22:57 -05:00
Deluan
c3a4585c83 chore(plugins): move Discord Rich Presence plugin to its own repository: https://github.com/navidrome/discord-rich-presence-plugin 2026-02-03 11:41:49 -05:00
Deluan
2068e7d413 fix(plugins): don't recording metrics for not implemented plugin calls
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 10:15:12 -05:00
242 changed files with 8468 additions and 3068 deletions

View File

@@ -15,4 +15,5 @@ dist
binaries
cache
music
music.old
!Dockerfile

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 }}

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ cache/*
coverage.out
dist
music
music.old
*.db*
.gitinfo
docker-compose.yml

View File

@@ -20,7 +20,7 @@ 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
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

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

@@ -252,7 +252,7 @@ var _ = Describe("JWT Authentication", func() {
// Writer goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
for i := range 100 {
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
time.Sleep(1 * time.Millisecond)
}
@@ -260,7 +260,7 @@ var _ = Describe("JWT Authentication", func() {
// Reader goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
for range 100 {
cache.get()
time.Sleep(1 * time.Millisecond)
}

View File

@@ -13,12 +13,15 @@ package gotaglib
import (
"errors"
"fmt"
"io"
"io/fs"
"runtime/debug"
"strings"
"time"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"go.senan.xyz/taglib"
)
@@ -46,6 +49,7 @@ func (e extractor) Version() string {
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()
@@ -94,7 +98,17 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
// openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
}
}()
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
@@ -105,12 +119,17 @@ func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
// WithFilename provides a format detection hint via the file extension,
// since OpenStream alone relies on content-sniffing which fails for some files.
f, err = taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(filePath),
)
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc := func() {
closeFunc = func() {
f.Close()
file.Close()
}
@@ -241,7 +260,7 @@ func parseTIPL(tags map[string][]string) {
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part

View File

@@ -173,6 +173,9 @@ var _ = Describe("Extractor", func() {
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),

View File

@@ -31,6 +31,12 @@ var ignoredContent = []string{
`<a href="https://www.last.fm/music/`,
}
var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)
func cleanContent(content string) string {
return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
}
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
@@ -95,7 +101,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(a.Description.Summary) {
resp.Description = strings.TrimSpace(a.Description.Summary)
resp.Description = cleanContent(a.Description.Summary)
return &resp, nil
}
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
@@ -171,7 +177,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
return "", err
}
if isValidContent(a.Bio.Summary) {
return strings.TrimSpace(a.Bio.Summary), nil
return cleanContent(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
}

View File

@@ -80,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
@@ -535,7 +535,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
URL: "https://www.last.fm/music/Cher/Believe",
}))
Expect(httpClient.RequestCount).To(Equal(1))

View File

@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
resp := map[string]any{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
@@ -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

@@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != ""
}
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if mbid == "" {
return "", agents.ErrNotFound
}
url, err := l.client.getArtistUrl(ctx, mbid)
if err != nil {
return "", err
}
return url, nil
}
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, len(resp))
for i, t := range resp {
mbid := ""
if len(t.ArtistMBIDs) > 0 {
mbid = t.ArtistMBIDs[0]
}
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(resp))
for i, artist := range resp {
artists[i] = agents.Artist{
MBID: artist.MBID,
Name: artist.Name,
}
}
return artists, nil
}
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(resp))
for i, song := range resp {
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artist: song.Artist,
MBID: song.MBID,
Name: song.Name,
}
}
return songs, nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return listenBrainzConstructor(ds)
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
}
})
}
var (
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
)

View File

@@ -4,11 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
Describe("GetArtistUrl", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns artist url when MBID present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns error when url not present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when fetch calls fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when ListenBrainz returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
})
Describe("GetTopSongs", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
})
It("returns all tracks when asked", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
It("returns only one track when prompted", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
})
Describe("GetSimilarArtists", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
}))
})
})
Describe("GetSimilarTracks", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
})
})

View File

@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
resp := map[string]any{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return
}
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {

View File

@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false))
})
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
})
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))

View File

@@ -2,16 +2,29 @@ package listenbrainz
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
lbzApiUrl = "https://api.listenbrainz.org/1/"
labsBase = "https://labs.api.listenbrainz.org/"
)
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
type listenBrainzError struct {
Code int
Message string
@@ -44,7 +57,7 @@ type listenBrainzResponse struct {
}
type listenBrainzRequest struct {
ApiKey string
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}
@@ -62,14 +75,14 @@ const (
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info"`
}
type additionalInfo struct {
@@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
@@ -104,7 +117,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) {
return u.String(), nil
}
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
@@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
return &response, nil
}
type lbzHttpError struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError
jsonErr := decoder.Decode(&lbzError)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
}
return resp, err
}
type artistMetadataResult struct {
Rels struct {
OfficialHomepage string `json:"official homepage,omitempty"`
} `json:"rels,omitzero"`
}
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
params := url.Values{}
params.Add("artist_mbids", mbid)
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []artistMetadataResult
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
return "", ErrorNotFound
}
return response[0].Rels.OfficialHomepage, nil
}
type trackInfo struct {
ArtistName string `json:"artist_name"`
ArtistMBIDs []string `json:"artist_mbids"`
DurationMs uint32 `json:"length"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
}
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []trackInfo
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) > count {
return response[0:count], nil
}
return response, nil
}
type artist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
}
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var artists []artist
jsonErr := decoder.Decode(&artists)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(artists) > limit {
return artists[:limit], nil
}
return artists, nil
}
type recording struct {
MBID string `json:"recording_mbid"`
Name string `json:"recording_name"`
Artist string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
Score int `json:"score"`
}
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var recordings []recording
jsonErr := decoder.Decode(&recordings)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
// For whatever reason, labs API isn't guaranteed to give results in the proper order
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
slices.SortFunc(recordings, func(a, b recording) int {
return cmp.Or(
cmp.Compare(b.Score, a.Score), // Sort by score descending
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
)
})
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
return a.MBID == b.MBID
})
if len(recordings) > limit {
return recordings[:limit], nil
}
return recordings, nil
}

View File

@@ -4,10 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -117,4 +120,345 @@ var _ = Describe("client", func() {
})
})
})
Context("getArtistUrl", func() {
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without meaningful body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 501,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns not found when the artist has no official homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err.Error()).To(Equal("listenbrainz: not found"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns data when the artist has a homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("http://projectmili.com/"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getArtistTopSongs", func() {
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without standard body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 500,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns all tracks when given the opportunity", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 174000,
RecordingName: "String Theocracy",
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
ReleaseName: "String Theocracy",
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns a subset of tracks when allowed", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getSimilarArtists", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
}
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarArtists(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
}))
})
It("fetches a different endpoint when algorithm changes", func() {
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
})
Context("getSimilarRecordings", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
}
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
It("properly sorts by score and truncates duplicates", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
// There are actually 5 items. The dedup should happen FIRST
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
Name: "Everybody Wants to Rule the World",
Artist: "Tears for Fears",
ReleaseName: "Songs From the Big Chair",
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
Score: 68,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
{
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
Name: "Tainted Love",
Artist: "Soft Cell",
ReleaseName: "Non-Stop Erotic Cabaret",
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
Score: 61,
},
}))
})
It("uses a different algorithm when configured", func() {
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
})
})

View File

@@ -73,7 +73,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
auth := c.id + ":" + c.secret
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
response := map[string]interface{}{}
response := map[string]any{}
err := c.makeRequest(req, &response)
if err != nil {
return "", err
@@ -86,7 +86,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
return "", errors.New("invalid response")
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
@@ -171,8 +172,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
@@ -182,7 +183,7 @@ type lastfmOptions struct {
type spotifyOptions struct {
ID string
Secret string
Secret string //nolint:gosec
}
type deezerOptions struct {
@@ -194,8 +195,10 @@ type deezerOptions struct {
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
Enabled bool
BaseURL string
ArtistAlgorithm string
TrackAlgorithm string
}
type httpHeaderOptions struct {
@@ -205,7 +208,7 @@ type httpHeaderOptions struct {
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
Password string //nolint:gosec
}
type AudioDeviceDefinition []string
@@ -431,7 +434,7 @@ func mapDeprecatedOption(legacyName, newName string) {
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{}
var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -464,7 +467,7 @@ func disableExternalServices() {
}
func validatePlaylistsPath() error {
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
log.Error("Invalid PlaylistsPath", "path", path, err)
@@ -478,7 +481,7 @@ func validatePlaylistsPath() error {
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
func parseLanguages(lang string) []string {
var languages []string
for _, l := range strings.Split(lang, ",") {
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l != "" {
languages = append(languages, l)
@@ -492,13 +495,7 @@ func parseLanguages(lang string) []string {
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := false
for _, v := range allowedValues {
if v == Server.Scanner.PurgeMissing {
valid = true
break
}
}
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
@@ -643,7 +640,8 @@ func setViperDefaults() {
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
@@ -655,7 +653,9 @@ func setViperDefaults() {
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
@@ -748,7 +748,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

@@ -74,6 +74,10 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second
DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')

View File

@@ -365,7 +365,7 @@ var _ = Describe("Agents", func() {
})
type mockAgent struct {
Args []interface{}
Args []any
Err error
}
@@ -374,7 +374,7 @@ func (a *mockAgent) AgentName() string {
}
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
a.Args = []interface{}{id, name}
a.Args = []any{id, name}
if a.Err != nil {
return "", a.Err
}
@@ -382,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
}
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []interface{}{id, name, mbid}
a.Args = []any{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -390,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
}
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []interface{}{id, name, mbid}
a.Args = []any{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -398,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
}
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
a.Args = []interface{}{id, name, mbid}
a.Args = []any{id, name, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -409,7 +409,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
}
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
a.Args = []interface{}{id, name, mbid, limit}
a.Args = []any{id, name, mbid, limit}
if a.Err != nil {
return nil, a.Err
}
@@ -420,7 +420,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
}
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, artistName, mbid, count}
a.Args = []any{id, artistName, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -431,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
}
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
a.Args = []interface{}{name, artist, mbid}
a.Args = []any{name, artist, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -444,7 +444,7 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
}
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, artist, mbid, count}
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -455,7 +455,7 @@ func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist,
}
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, artist, mbid, count}
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -466,7 +466,7 @@ func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist,
}
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, mbid, count}
a.Args = []any{id, name, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -488,12 +488,12 @@ type testImageAgent struct {
Name string
Images []ExternalImage
Err error
Args []interface{}
Args []any
}
func (t *testImageAgent) AgentName() string { return t.Name }
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
t.Args = []interface{}{id, name, mbid}
t.Args = []any{id, name, mbid}
return t.Images, t.Err
}

View File

@@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() {
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := 0; i < 5; i++ {
for i := range 5 {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}

View File

@@ -79,7 +79,7 @@ func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string,
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":

View File

@@ -99,7 +99,7 @@ func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
var ff []sourceFunc
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "external":
@@ -116,7 +116,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
current := artistFolder
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
for range maxArtistFolderTraversalDepth {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
}

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

@@ -4,6 +4,7 @@ import (
"cmp"
"context"
"crypto/sha256"
"maps"
"sync"
"time"
@@ -53,9 +54,7 @@ func createBaseClaims() map[string]any {
func CreatePublicToken(claims map[string]any) (string, error) {
tokenClaims := createBaseClaims()
for k, v := range claims {
tokenClaims[k] = v
}
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
return token, err
@@ -66,9 +65,7 @@ func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, er
if !exp.IsZero() {
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
}
for k, v := range claims {
tokenClaims[k] = v
}
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
return token, err
@@ -100,7 +97,7 @@ func TouchToken(token jwt.Token) (string, error) {
return newToken, err
}
func Validate(tokenStr string) (map[string]interface{}, error) {
func Validate(tokenStr string) (map[string]any, error) {
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
if err != nil {
return nil, err

View File

@@ -45,7 +45,7 @@ var _ = Describe("Auth", func() {
})
It("returns the claims from a valid JWT token", func() {
claims := map[string]interface{}{}
claims := map[string]any{}
claims["iss"] = "issuer"
claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
@@ -58,7 +58,7 @@ var _ = Describe("Auth", func() {
})
It("returns ErrExpired if the `exp` field is in the past", func() {
claims := map[string]interface{}{}
claims := map[string]any{}
claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
_, tokenStr, err := auth.TokenAuth.Encode(claims)
@@ -93,7 +93,7 @@ var _ = Describe("Auth", func() {
Describe("TouchToken", func() {
It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay)
claims := map[string]interface{}{}
claims := map[string]any{}
claims["iss"] = "issuer"
claims["exp"] = yesterday.Unix()
token, _, err := auth.TokenAuth.Encode(claims)

View File

@@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
// GetAll implements model.ArtistRepository.
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
argsSlice := make([]interface{}, len(options))
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -99,7 +99,7 @@ func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ..
// GetAll implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]interface{}, len(options))
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -152,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
// GetAll implements model.AlbumRepository.
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
argsSlice := make([]interface{}, len(options))
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}

View File

@@ -93,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider {
}
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
var entity interface{}
var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxAlbum{}, err
@@ -187,7 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
}
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
var entity interface{}
var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxArtist{}, err

View File

@@ -159,7 +159,7 @@ type libraryRepositoryWrapper struct {
pluginManager PluginUnloader
}
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
lib := entity.(*model.Library)
if err := r.validateLibrary(lib); err != nil {
return "", err
@@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {

View File

@@ -196,9 +196,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
// refreshStatsAsync refreshes artist and album statistics in background goroutines
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
// Refresh artist stats in background
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.wg.Go(func() {
bgCtx := request.AddValues(context.Background(), ctx)
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
@@ -214,7 +212,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
}
}
}()
})
}
// Wait waits for all background goroutines to complete.

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
@@ -220,7 +220,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

@@ -3,6 +3,7 @@ package playback
import (
"fmt"
"math/rand"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -21,11 +22,11 @@ func NewQueue() *Queue {
}
func (pd *Queue) String() string {
filenames := ""
var filenames strings.Builder
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
}
// returns the current mediafile or nil

View File

@@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
@@ -193,8 +193,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
if after, ok := strings.CutPrefix(line, "file://"); ok {
line = after
line, _ = url.QueryUnescape(line)
}
if !model.IsAudioFile(line) {
@@ -533,7 +533,7 @@ type nspFile struct {
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]interface{}{}
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err

View File

@@ -212,10 +212,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
// Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL.
remaining := int(mf.Duration) - position
if remaining < 0 {
remaining = 0
}
remaining := max(int(mf.Duration)-position, 0)
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)

View File

@@ -87,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
}
}
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s := entity.(*model.Share)
id, err := r.newId()
if err != nil {
@@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return id, err
}
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration

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

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/fs"
"maps"
"net/url"
"path"
"testing/fstest"
@@ -135,9 +136,7 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
if err != nil {
panic(err)
}
for k, v := range newTags {
tags[k] = v
}
maps.Copy(tags, newTags)
data, _ := json.Marshal(tags)
f.Data = data
ffs.Touch(filePath, when...)
@@ -180,9 +179,7 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
ts["title"] = title
ts["track"] = num
for _, t := range tags {
for k, v := range t {
ts[k] = v
}
maps.Copy(ts, t)
}
return ts
}
@@ -200,9 +197,7 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
func File(tags ...map[string]any) *fstest.MapFile {
ts := map[string]any{}
for _, t := range tags {
for k, v := range t {
ts[k] = v
}
maps.Copy(ts, t)
}
modTime := time.Now()
if mt, ok := ts[fakeFileInfoModTime]; !ok {

View File

@@ -50,12 +50,12 @@ type userRepositoryWrapper struct {
}
// Save implements rest.Persistable by delegating to the underlying repository.
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *userRepositoryWrapper) Save(entity any) (string, error) {
return r.UserRepository.(rest.Persistable).Save(entity)
}
// Update implements rest.Persistable by delegating to the underlying repository.
func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
}

View File

@@ -6,7 +6,9 @@ import (
"embed"
"fmt"
"runtime"
"strings"
"github.com/maruel/natural"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
@@ -31,7 +33,12 @@ func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
return err
}
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
})
},
})
Path = conf.Server.DbPath
@@ -126,7 +133,7 @@ func Optimize(ctx context.Context) {
}
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn
for i := 0; i < numConns; i++ {
for range numConns {
conn, err := Db().Conn(ctx)
conns = append(conns, conn)
if err != nil {
@@ -147,8 +154,8 @@ func Optimize(ctx context.Context) {
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...any) {
if len(v) < 1 {
return
}
@@ -183,27 +190,27 @@ type logAdapter struct {
silent bool
}
func (l *logAdapter) Fatal(v ...interface{}) {
func (l *logAdapter) Fatal(v ...any) {
log.Fatal(l.ctx, fmt.Sprint(v...))
}
func (l *logAdapter) Fatalf(format string, v ...interface{}) {
func (l *logAdapter) Fatalf(format string, v ...any) {
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
}
func (l *logAdapter) Print(v ...interface{}) {
func (l *logAdapter) Print(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprint(v...))
}
}
func (l *logAdapter) Println(v ...interface{}) {
func (l *logAdapter) Println(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintln(v...))
}
}
func (l *logAdapter) Printf(format string, v ...interface{}) {
func (l *logAdapter) Printf(format string, v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintf(format, v...))
}

View File

@@ -0,0 +1,152 @@
-- +goose Up
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
-- This way bare ORDER BY on these columns automatically uses natural sorting,
-- without needing explicit COLLATE NATURALSORT in every query.
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name collate NATURALSORT);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name collate NATURALSORT);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name collate NATURALSORT);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title collate NATURALSORT);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name collate NATURALSORT);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name collate NATURALSORT);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
drop index if exists playlist_name;
create index playlist_name
on playlist (name collate NATURALSORT);
drop index if exists radio_name;
create index radio_name
on radio (name collate NATURALSORT);
-- +goose Down
-- Restore NOCASE column collation
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Restore NOCASE collation indexes
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name, order_album_name);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
-- Restore playlist and radio indexes
drop index if exists playlist_name;
create index playlist_name
on playlist (name);
drop index if exists radio_name;
create index radio_name
on radio (name);

36
go.mod
View File

@@ -7,14 +7,14 @@ 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-20260119020817-8753c7531798
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
)
require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.9.2
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -28,7 +28,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -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.27.5
github.com/onsi/gomega v1.39.0
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
@@ -68,12 +68,12 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.35.0
golang.org/x/net v0.49.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
golang.org/x/term v0.39.0
golang.org/x/text v0.33.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -98,7 +98,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -134,16 +134,16 @@ require (
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

72
go.sum
View File

@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -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-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/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=
@@ -77,8 +77,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -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=
@@ -197,10 +197,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -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=
@@ -301,8 +301,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -319,20 +319,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -344,8 +344,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -370,11 +370,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -383,8 +383,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -395,8 +395,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -406,8 +406,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -19,7 +19,7 @@ import (
type Level uint32
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
var redacted = &Hook{
AcceptedLevels: logrus.AllLevels,
@@ -152,7 +152,7 @@ func Redact(msg string) string {
return r
}
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
if ctx == nil {
ctx = context.Background()
}
@@ -184,32 +184,32 @@ func IsGreaterOrEqualTo(level Level) bool {
return shouldLog(level, 2)
}
func Fatal(args ...interface{}) {
func Fatal(args ...any) {
Log(LevelFatal, args...)
os.Exit(1)
}
func Error(args ...interface{}) {
func Error(args ...any) {
Log(LevelError, args...)
}
func Warn(args ...interface{}) {
func Warn(args ...any) {
Log(LevelWarn, args...)
}
func Info(args ...interface{}) {
func Info(args ...any) {
Log(LevelInfo, args...)
}
func Debug(args ...interface{}) {
func Debug(args ...any) {
Log(LevelDebug, args...)
}
func Trace(args ...interface{}) {
func Trace(args ...any) {
Log(LevelTrace, args...)
}
func Log(level Level, args ...interface{}) {
func Log(level Level, args ...any) {
if !shouldLog(level, 3) {
return
}
@@ -250,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
return false
}
func parseArgs(args []interface{}) (*logrus.Entry, string) {
func parseArgs(args []any) (*logrus.Entry, string) {
var l *logrus.Entry
var err error
if args[0] == nil {
@@ -289,7 +289,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
return l, ""
}
func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
for i := 0; i < len(keyValuePairs); i += 2 {
switch name := keyValuePairs[i].(type) {
case error:
@@ -316,7 +316,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry
return logger
}
func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func extractLogger(ctx any) (*logrus.Entry, error) {
switch ctx := ctx.(type) {
case *logrus.Entry:
return ctx, nil

View File

@@ -23,6 +23,7 @@ var fieldMap = map[string]*mappedField{
"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"},

View File

@@ -41,7 +41,7 @@ type DataStore interface {
Scrobble(ctx context.Context) ScrobbleRepository
Plugin(ctx context.Context) PluginRepository
Resource(ctx context.Context, model interface{}) ResourceRepository
Resource(ctx context.Context, model any) ResourceRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error

View File

@@ -5,7 +5,7 @@ import (
)
// TODO: Should the type be encoded in the ID?
func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id)
if err == nil {
return ar, nil

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"`
@@ -140,7 +140,7 @@ func (mf MediaFile) Hash() string {
}
hash, _ := hashstructure.Hash(mf, opts)
sum := md5.New()
sum.Write([]byte(fmt.Sprintf("%d", hash)))
sum.Write(fmt.Appendf(nil, "%d", hash))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil))

View File

@@ -250,7 +250,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
id3Base := parseID3Pairs(name, lowered)
if len(aliasValues) > 0 {
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
// For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
// should not be interpreted as language keys (e.g. "(intro)" is not a language)
if name == model.TagLyrics {
for _, v := range aliasValues {
id3Base = append(id3Base, NewPair("xxx", v))
}
} else {
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
}
}
return id3Base
}
@@ -260,8 +268,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
prefix := string(name) + ":"
for tagKey, tagValues := range lowered {
keyStr := string(tagKey)
if strings.HasPrefix(keyStr, prefix) {
keyPart := strings.TrimPrefix(keyStr, prefix)
if after, ok := strings.CutPrefix(keyStr, prefix); ok {
keyPart := after
if keyPart == string(name) {
keyPart = ""
}

View File

@@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() {
metadata.NewPair("eng", "Lyrics"),
))
})
It("should preserve lyrics starting with parentheses from alias tags", func() {
props.Tags = model.RawTags{
"LYRICS": {"(line one)\nline two\nline three"},
}
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagLyrics))
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
))
})
})
Describe("ReplayGain", func() {

View File

@@ -49,8 +49,8 @@ func createGetPID(hash hashFunc) getPIDFunc {
}
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := ""
fields := strings.Split(spec, "|")
for _, field := range fields {
fields := strings.SplitSeq(spec, "|")
for field := range fields {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {

View File

@@ -51,13 +51,13 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) {
}
// Split by the first colon
colonIdx := strings.Index(part, ":")
if colonIdx == -1 {
before, after, ok := strings.Cut(part, ":")
if !ok {
return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
}
libIDStr := part[:colonIdx]
folderPath := part[colonIdx+1:]
libIDStr := before
folderPath := after
libID, err := strconv.Atoi(libIDStr)
if err != nil {

View File

@@ -22,8 +22,8 @@ type Share struct {
Format string `structs:"format" json:"format,omitempty"`
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
Albums Albums `structs:"-" json:"albums,omitempty"`
URL string `structs:"-" json:"-"`

View File

@@ -144,10 +144,8 @@ func (t Tags) Merge(tags Tags) {
}
func (t Tags) Add(name TagName, v string) {
for _, existing := range t[name] {
if existing == v {
return
}
if slices.Contains(t[name], v) {
return
}
t[name] = append(t[name], v)
}

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

@@ -145,11 +145,11 @@ func recentlyAddedSort() string {
return "created_at"
}
func recentlyPlayedFilter(string, interface{}) Sqlizer {
func recentlyPlayedFilter(string, any) Sqlizer {
return Gt{"play_count": 0}
}
func yearFilter(_ string, value interface{}) Sqlizer {
func yearFilter(_ string, value any) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
@@ -160,14 +160,14 @@ func yearFilter(_ string, value interface{}) Sqlizer {
}
}
func artistFilter(_ string, value interface{}) Sqlizer {
func artistFilter(_ string, value any) Sqlizer {
return Or{
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
}
}
func artistRoleFilter(name string, value interface{}) Sqlizer {
func artistRoleFilter(name string, value any) Sqlizer {
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
// Check if the role name is valid. If not, return an invalid filter
@@ -177,7 +177,7 @@ func artistRoleFilter(name string, value interface{}) Sqlizer {
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
}
func allRolesFilter(_ string, value interface{}) Sqlizer {
func allRolesFilter(_ string, value any) Sqlizer {
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
}
@@ -248,7 +248,7 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
if err != nil {
return fmt.Errorf("getting album to copy fields from: %w", err)
}
to := make(map[string]interface{})
to := make(map[string]any)
for _, col := range columns {
to[col] = from[col]
}
@@ -370,11 +370,11 @@ func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *albumRepository) Read(id string) (interface{}, error) {
func (r *albumRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -382,7 +382,7 @@ func (r *albumRepository) EntityName() string {
return "album"
}
func (r *albumRepository) NewInstance() interface{} {
func (r *albumRepository) NewInstance() any {
return &model.Album{}
}

View File

@@ -162,7 +162,7 @@ var _ = Describe("AlbumRepository", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
for i := 0; i < playCount; i++ {
for range playCount {
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
}
@@ -185,7 +185,7 @@ var _ = Describe("AlbumRepository", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
for i := 0; i < playCount; i++ {
for range playCount {
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
}
@@ -406,7 +406,7 @@ var _ = Describe("AlbumRepository", func() {
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal([]interface{}{artistID}))
Expect(args).To(Equal([]any{artistID}))
},
Entry("artist role", "role_artist_id", "123",
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
@@ -428,7 +428,7 @@ var _ = Describe("AlbumRepository", func() {
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
Expect(args).To(Equal([]interface{}{"test-id"}))
Expect(args).To(Equal([]any{"test-id"}))
}
})

View File

@@ -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",
@@ -164,7 +164,7 @@ func roleFilter(_ string, role any) Sqlizer {
}
// artistLibraryIdFilter filters artists based on library access through the library_artist table
func artistLibraryIdFilter(_ string, value interface{}) Sqlizer {
func artistLibraryIdFilter(_ string, value any) Sqlizer {
return Eq{"library_artist.library_id": value}
}
@@ -534,11 +534,11 @@ func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *artistRepository) Read(id string) (interface{}, error) {
func (r *artistRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
role := "total"
if len(options) > 0 {
if v, ok := options[0].Filters["role"].(string); ok {
@@ -555,7 +555,7 @@ func (r *artistRepository) EntityName() string {
return "artist"
}
func (r *artistRepository) NewInstance() interface{} {
func (r *artistRepository) NewInstance() any {
return &model.Artist{}
}

View File

@@ -17,45 +17,45 @@ import (
var _ = Describe("Collation", func() {
conn := db.Db()
DescribeTable("Column collation",
func(table, column string) {
Expect(checkCollation(conn, table, column)).To(Succeed())
func(table, column, expectedCollation string) {
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
Entry("album.order_album_name", "album", "order_album_name"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
Entry("album.sort_album_name", "album", "sort_album_name"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
Entry("media_file.order_title", "media_file", "order_title"),
Entry("media_file.order_album_name", "media_file", "order_album_name"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
Entry("media_file.sort_title", "media_file", "sort_title"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
Entry("playlist.name", "playlist", "name"),
Entry("radio.name", "radio", "name"),
Entry("user.name", "user", "name"),
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
Entry("radio.name", "radio", "name", "NATURALSORT"),
Entry("user.name", "user", "name", "NOCASE"),
)
DescribeTable("Index collation",
func(table, column string) {
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("media_file.path", "media_file", "path collate nocase"),
Entry("playlist.name", "playlist", "name collate nocase"),
Entry("radio.name", "radio", "name collate nocase"),
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
Entry("radio.name", "radio", "name collate NATURALSORT"),
Entry("user.user_name", "user", "user_name collate nocase"),
)
})
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
return errors.New("no rows returned")
}
func checkCollation(conn *sql.DB, table string, column string) error {
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
if err != nil {
return err
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
if !re.MatchString(res) {
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
}
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
if re.MatchString(res) {
return nil
}
} else {
return fmt.Errorf("table '%s' not found", table)
}
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
@@ -117,9 +118,7 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...
if err != nil {
return nil, err
}
for id, info := range batchResult {
result[id] = info
}
maps.Copy(result, batchResult)
}
return result, nil

View File

@@ -33,18 +33,18 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
// Override ResourceRepository methods to return Genre objects instead of Tag objects
func (r *genreRepository) Read(id string) (interface{}, error) {
func (r *genreRepository) Read(id string) (any, error) {
sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre
err := r.queryOne(sel, &res)
return &res, err
}
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *genreRepository) NewInstance() interface{} {
func (r *genreRepository) NewInstance() any {
return &model.Genre{}
}

View File

@@ -182,7 +182,7 @@ var _ = Describe("GenreRepository", func() {
It("should filter by name using like match", func() {
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%rock%"},
Filters: map[string]any{"name": "%rock%"},
}
count, err := restRepo.Count(options)
Expect(err).ToNot(HaveOccurred())
@@ -289,7 +289,7 @@ var _ = Describe("GenreRepository", func() {
It("should allow headless processes to apply explicit library_id filters", func() {
// Filter by specific library
genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{"library_id": 2},
Filters: map[string]any{"library_id": 2},
})
Expect(err).ToNot(HaveOccurred())

View File

@@ -15,7 +15,7 @@ type PostMapper interface {
PostMapArgs(map[string]any) error
}
func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
func toSQLArgs(rec any) (map[string]any, error) {
m := structs.Map(rec)
for k, v := range m {
switch t := v.(type) {
@@ -71,7 +71,7 @@ type existsCond struct {
not bool
}
func (e existsCond) ToSql() (string, []interface{}, error) {
func (e existsCond) ToSql() (string, []any, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
if e.not {
@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []interface{}, error) {
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
// Convert the order_* columns to an expression using sort_* columns. Example:
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
// It finds order column names anywhere in the substring
func mapSortOrder(tableName, order string) string {
order = strings.ToLower(order)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
}

View File

@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
sort := "ORDER_ALBUM_NAME asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
` collate nocase) asc`))
` collate NATURALSORT) asc`))
})
It("changes multiple order columns to sort expressions", func() {
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
})
})
})

View File

@@ -305,7 +305,7 @@ func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *libraryRepository) Read(id string) (interface{}, error) {
func (r *libraryRepository) Read(id string) (any, error) {
idInt, err := strconv.Atoi(id)
if err != nil {
log.Trace(r.ctx, "invalid library id: %s", id, err)
@@ -314,7 +314,7 @@ func (r *libraryRepository) Read(id string) (interface{}, error) {
return r.Get(idInt)
}
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -322,11 +322,11 @@ func (r *libraryRepository) EntityName() string {
return "library"
}
func (r *libraryRepository) NewInstance() interface{} {
func (r *libraryRepository) NewInstance() any {
return &model.Library{}
}
func (r *libraryRepository) Save(entity interface{}) (string, error) {
func (r *libraryRepository) Save(entity any) (string, error) {
lib := entity.(*model.Library)
lib.ID = 0 // Reset ID to ensure we create a new library
err := r.Put(lib)
@@ -336,7 +336,7 @@ func (r *libraryRepository) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *libraryRepository) Update(id string, entity any, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {

View File

@@ -148,7 +148,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
@@ -443,11 +445,11 @@ func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error)
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
func (r *mediaFileRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -455,7 +457,7 @@ func (r *mediaFileRepository) EntityName() string {
return "mediafile"
}
func (r *mediaFileRepository) NewInstance() interface{} {
func (r *mediaFileRepository) NewInstance() any {
return &model.MediaFile{}
}

View File

@@ -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())
@@ -310,7 +372,7 @@ var _ = Describe("MediaRepository", func() {
// Update "Old Song": created long ago, updated recently
_, err := db.Update("media_file",
map[string]interface{}{
map[string]any{
"created_at": oldTime,
"updated_at": newTime,
},
@@ -319,7 +381,7 @@ var _ = Describe("MediaRepository", func() {
// Update "Middle Song": created and updated at the same middle time
_, err = db.Update("media_file",
map[string]interface{}{
map[string]any{
"created_at": middleTime,
"updated_at": middleTime,
},
@@ -328,7 +390,7 @@ var _ = Describe("MediaRepository", func() {
// Update "New Song": created recently, updated long ago
_, err = db.Update("media_file",
map[string]interface{}{
map[string]any{
"created_at": newTime,
"updated_at": oldTime,
},

View File

@@ -97,7 +97,7 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
return NewPluginRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.User:
return s.User(ctx).(model.ResourceRepository)

View File

@@ -103,14 +103,14 @@ func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playerRepository) Read(id string) (interface{}, error) {
func (r *playerRepository) Read(id string) (any, error) {
sel := r.newRestSelect().Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
res := model.Players{}
err := r.queryAll(sel, &res)
@@ -121,7 +121,7 @@ func (r *playerRepository) EntityName() string {
return "player"
}
func (r *playerRepository) NewInstance() interface{} {
func (r *playerRepository) NewInstance() any {
return &model.Player{}
}
@@ -130,7 +130,7 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
return u.IsAdmin || p.UserId == u.ID
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
func (r *playerRepository) Save(entity any) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
@@ -142,7 +142,7 @@ func (r *playerRepository) Save(entity interface{}) (string, error) {
return id, err
}
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
if !r.isPermitted(t) {

View File

@@ -61,14 +61,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
return r
}
func playlistFilter(_ string, value interface{}) Sqlizer {
func playlistFilter(_ string, value any) Sqlizer {
return Or{
substringFilter("playlist.name", value),
substringFilter("playlist.comment", value),
}
}
func smartPlaylistFilter(string, interface{}) Sqlizer {
func smartPlaylistFilter(string, any) Sqlizer {
return Or{
Eq{"rules": ""},
Eq{"rules": nil},
@@ -285,13 +285,16 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
}
// Update when the playlist was last refreshed (for cache purposes)
updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID})
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
_, err = r.executeSQL(updSql)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
@@ -418,11 +421,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error)
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistRepository) Read(id string) (interface{}, error) {
func (r *playlistRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -430,11 +433,11 @@ func (r *playlistRepository) EntityName() string {
return "playlist"
}
func (r *playlistRepository) NewInstance() interface{} {
func (r *playlistRepository) NewInstance() any {
return &model.Playlist{}
}
func (r *playlistRepository) Save(entity interface{}) (string, error) {
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
@@ -445,7 +448,7 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) {
return pls.ID, err
}
func (r *playlistRepository) Update(id string, entity interface{}, cols ...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 {

View File

@@ -4,6 +4,7 @@ import (
"time"
"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/criteria"
@@ -160,14 +161,23 @@ var _ = Describe("PlaylistRepository", func() {
})
})
// TODO Validate these tests
XContext("child smart playlists", func() {
When("refresh day has expired", func() {
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
@@ -175,45 +185,69 @@ var _ = Describe("PlaylistRepository", func() {
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
_, err = repo.GetWithTracks(parentPls.ID, true, false)
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh day has not expired", func() {
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.GetWithTracks(parentPls.ID, true, false)
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})

View File

@@ -84,7 +84,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
return r.count(query, r.parseRestOptions(r.ctx, options...))
}
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
func (r *playlistTrackRepository) Read(id string) (any, error) {
userID := loggedUser(r.ctx).ID
sel := r.newSelect().
LeftJoin("annotation on ("+
@@ -128,7 +128,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
return ids, nil
}
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -136,7 +136,7 @@ func (r *playlistTrackRepository) EntityName() string {
return "playlist_tracks"
}
func (r *playlistTrackRepository) NewInstance() interface{} {
func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}

View File

@@ -122,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
UpdatedAt: pq.UpdatedAt,
}
if strings.TrimSpace(pq.Items) != "" {
tracks := strings.Split(pq.Items, ",")
for _, t := range tracks {
tracks := strings.SplitSeq(pq.Items, ",")
for t := range tracks {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}

View File

@@ -63,7 +63,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
return rest.ErrPermissionDenied
}
var values map[string]interface{}
var values map[string]any
radio.UpdatedAt = time.Now()
@@ -97,19 +97,19 @@ func (r *radioRepository) EntityName() string {
return "radio"
}
func (r *radioRepository) NewInstance() interface{} {
func (r *radioRepository) NewInstance() any {
return &model.Radio{}
}
func (r *radioRepository) Read(id string) (interface{}, error) {
func (r *radioRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *radioRepository) Save(entity interface{}) (string, error) {
func (r *radioRepository) Save(entity any) (string, error) {
t := entity.(*model.Radio)
if !r.isPermitted() {
return "", rest.ErrPermissionDenied
@@ -121,7 +121,7 @@ func (r *radioRepository) Save(entity interface{}) (string, error) {
return t.ID, err
}
func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *radioRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Radio)
t.ID = id
if !r.isPermitted() {

View File

@@ -51,7 +51,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) {
}
func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
ins := Insert(r.tableName).SetMap(map[string]interface{}{
ins := Insert(r.tableName).SetMap(map[string]any{
"id": id.NewRandom(),
"user_id": userId,
"service": service,

View File

@@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
id := id.NewRandom()
ids = append(ids, id)
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
"id": id,
"user_id": userId,
"service": service,

View File

@@ -23,7 +23,7 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe
func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
userID := loggedUser(r.ctx).ID
values := map[string]interface{}{
values := map[string]any{
"media_file_id": mediaFileID,
"user_id": userID,
"submission_time": submissionTime.Unix(),

View File

@@ -138,7 +138,7 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
return sorted
}
func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
s := entity.(*model.Share)
// TODO Validate record
s.ID = id
@@ -151,7 +151,7 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string)
return err
}
func (r *shareRepository) Save(entity interface{}) (string, error) {
func (r *shareRepository) Save(entity any) (string, error) {
s := entity.(*model.Share)
// TODO Validate record
u := loggedUser(r.ctx)
@@ -179,18 +179,18 @@ func (r *shareRepository) EntityName() string {
return "share"
}
func (r *shareRepository) NewInstance() interface{} {
func (r *shareRepository) NewInstance() any {
return &model.Share{}
}
func (r *shareRepository) Read(id string) (interface{}, error) {
func (r *shareRepository) Read(id string) (any, error) {
sel := r.selectShare().Where(Eq{"share.id": id})
var res model.Share
err := r.queryOne(sel, &res)
return &res, err
}
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
res := model.Shares{}
err := r.queryAll(sq, &res)

View File

@@ -47,7 +47,7 @@ var _ = Describe("ShareRepository", func() {
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]interface{}{
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Test Share",
@@ -79,7 +79,7 @@ var _ = Describe("ShareRepository", func() {
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]interface{}{
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Get Share",
@@ -110,7 +110,7 @@ var _ = Describe("ShareRepository", func() {
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]interface{}{
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "SQL Test Share",

View File

@@ -66,7 +66,7 @@ func (r sqlRepository) annId(itemID ...string) And {
}
}
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error {
upd := Update(annotationTable).Where(r.annId(itemIDs...))
for f, v := range values {
upd = upd.Set(f, v)
@@ -90,12 +90,12 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
ratedAt := time.Now()
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
if err != nil {
return err
}
@@ -121,7 +121,7 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
if c == 0 || errors.Is(err, sql.ErrNoRows) {
userID := loggedUser(r.ctx).ID
values := map[string]interface{}{}
values := map[string]any{}
values["user_id"] = userID
values["item_type"] = r.tableName
values["item_id"] = itemID

View File

@@ -32,17 +32,17 @@ var _ = Describe("Annotation Filters", func() {
Describe("annotationBoolFilter", func() {
DescribeTable("creates correct SQL expressions",
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
func(field, value string, expectedSQL string, expectedArgs []any) {
sqlizer := annotationBoolFilter(field)(field, value)
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal(expectedArgs))
},
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
)
It("returns nil if value is not a string", func() {

View File

@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
//
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
// which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,),order_title) collate nocase)
// To avoid performance issues, indexes should be created for these sort expressions
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
// To avoid performance issues, indexes should be created for these sort expressions.
//
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
@@ -196,7 +196,7 @@ func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
}
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
func libraryIdFilter(_ string, value interface{}) Sqlizer {
func libraryIdFilter(_ string, value any) Sqlizer {
return Eq{"library_id": value}
}
@@ -281,7 +281,7 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
return result, params, nil
}
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
query, args, err := r.toSQL(sq)
if err != nil {
return err
@@ -328,7 +328,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ..
}, nil
}
func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error {
func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error {
if len(options) > 0 && options[0].Offset > 0 {
sq = r.optimizePagination(sq, options[0])
}
@@ -347,7 +347,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options
}
// queryAllSlice is a helper function to query a single column and return the result in a slice
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error {
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error {
query, args, err := r.toSQL(sq)
if err != nil {
return err
@@ -394,7 +394,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
return res.Count, err
}
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) {
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) {
if id != "" {
return r.put(id, m, colsToUpdate...)
}
@@ -408,14 +408,14 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, cols
return r.put(res.ID, m, colsToUpdate...)
}
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
values, err := toSQLArgs(m)
if err != nil {
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
}
// If there's an ID, try to update first
if id != "" {
updateValues := map[string]interface{}{}
updateValues := map[string]any{}
// This is a map of the columns that need to be updated, if specified
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {

View File

@@ -37,7 +37,7 @@ func (r sqlRepository) bmkID(itemID ...string) And {
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
client, _ := request.ClientFrom(r.ctx)
user, _ := request.UserFrom(r.ctx)
values := map[string]interface{}{
values := map[string]any{
"comment": comment,
"position": position,
"updated_at": time.Now(),

View File

@@ -30,7 +30,7 @@ var _ = Describe("sqlRestful", func() {
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
options.Filters = map[string]interface{}{"name": "'"}
options.Filters = map[string]any{"name": "'"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
@@ -40,32 +40,32 @@ var _ = Describe("sqlRestful", func() {
return nil
},
}
options.Filters = map[string]interface{}{"name": "joe"}
options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]interface{}{"id": "123"}
options.Filters = map[string]any{"id": "123"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
options.Filters = map[string]any{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
options.Filters = map[string]interface{}{"name": "joe"}
options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
"test": func(field string, value interface{}) squirrel.Sqlizer {
"test": func(field string, value any) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
options.Filters = map[string]interface{}{"test": 100}
options.Filters = map[string]any{"test": 100}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})

View File

@@ -60,7 +60,7 @@ func tagIDFilter(name string, idValue any) Sqlizer {
}
// tagLibraryIdFilter filters tags based on library access through the library_tag table
func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
func tagLibraryIdFilter(_ string, value any) Sqlizer {
return Eq{"library_tag.library_id": value}
}
@@ -142,14 +142,14 @@ func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(sq, r.parseRestOptions(r.ctx, options...))
}
func (r *baseTagRepository) Read(id string) (interface{}, error) {
func (r *baseTagRepository) Read(id string) (any, error) {
query := r.newSelect().Where(Eq{"id": id})
var res model.Tag
err := r.queryOne(query, &res)
return &res, err
}
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
query := r.newSelect(r.parseRestOptions(r.ctx, options...))
var res model.TagList
err := r.queryAll(query, &res)
@@ -160,7 +160,7 @@ func (r *baseTagRepository) EntityName() string {
return "tag"
}
func (r *baseTagRepository) NewInstance() interface{} {
func (r *baseTagRepository) NewInstance() any {
return model.Tag{}
}

View File

@@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should respect explicit library_id filters within accessible libraries", func() {
tags := readAllTags(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID2},
Filters: map[string]any{"library_id": libraryID2},
})
// Should see only tags from library 2: pop and rock(lib2)
Expect(tags).To(HaveLen(2))
@@ -174,7 +174,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should not return tags when filtering by inaccessible library", func() {
tags := readAllTags(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID3},
Filters: map[string]any{"library_id": libraryID3},
})
// Should return no tags since user can't access library 3
Expect(tags).To(HaveLen(0))
@@ -182,7 +182,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should filter by library 1 correctly", func() {
tags := readAllTags(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID1},
Filters: map[string]any{"library_id": libraryID1},
})
// Should see only rock from library 1
Expect(tags).To(HaveLen(1))
@@ -227,7 +227,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should allow headless processes to apply explicit library_id filters", func() {
tags := readAllTags(nil, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID3},
Filters: map[string]any{"library_id": libraryID3},
})
// Should see only jazz from library 3
Expect(tags).To(HaveLen(1))
@@ -243,7 +243,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should respect explicit library_id filters", func() {
tags := readAllTags(&adminUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID3},
Filters: map[string]any{"library_id": libraryID3},
})
// Should see only jazz from library 3
Expect(tags).To(HaveLen(1))
@@ -252,7 +252,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should filter by library 2 correctly", func() {
tags := readAllTags(&adminUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID2},
Filters: map[string]any{"library_id": libraryID2},
})
// Should see pop and rock from library 2
Expect(tags).To(HaveLen(2))

View File

@@ -234,7 +234,7 @@ var _ = Describe("TagRepository", func() {
It("should filter tags by partial value correctly", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock'
Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock'
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
@@ -249,7 +249,7 @@ var _ = Describe("TagRepository", func() {
It("should filter tags by partial value using LIKE", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e'
Filters: map[string]any{"name": "%e%"}, // Tags containing 'e'
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
@@ -264,7 +264,7 @@ var _ = Describe("TagRepository", func() {
It("should sort tags by value ascending", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
Sort: "name",
Order: "asc",
}
@@ -280,7 +280,7 @@ var _ = Describe("TagRepository", func() {
It("should sort tags by value descending", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
Sort: "name",
Order: "desc",
}

View File

@@ -52,11 +52,11 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
}
func (r *transcodingRepository) Read(id string) (interface{}, error) {
func (r *transcodingRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
res := model.Transcodings{}
err := r.queryAll(sel, &res)
@@ -67,11 +67,11 @@ func (r *transcodingRepository) EntityName() string {
return "transcoding"
}
func (r *transcodingRepository) NewInstance() interface{} {
func (r *transcodingRepository) NewInstance() any {
return &model.Transcoding{}
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
func (r *transcodingRepository) Save(entity any) (string, error) {
if !loggedUser(r.ctx).IsAdmin {
return "", rest.ErrPermissionDenied
}
@@ -83,7 +83,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
return id, err
}
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}

View File

@@ -1036,9 +1036,8 @@ See [examples/](examples/) for complete working plugins:
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
---

View File

@@ -1,5 +1,7 @@
package plugins
import "slices"
// Capability represents a plugin capability type.
// Capabilities are detected by checking which functions a plugin exports.
type Capability string
@@ -25,11 +27,8 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
var capabilities []Capability
for cap, functions := range capabilityFunctions {
for _, fn := range functions {
if plugin.FunctionExists(fn) {
capabilities = append(capabilities, cap)
break // Found at least one function, plugin has this capability
}
if slices.ContainsFunc(functions, plugin.FunctionExists) {
capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
}
}
@@ -38,10 +37,5 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
// hasCapability checks if the given capabilities slice contains a specific capability.
func hasCapability(capabilities []Capability, cap Capability) bool {
for _, c := range capabilities {
if c == cap {
return true
}
}
return false
return slices.Contains(capabilities, cap)
}

View File

@@ -282,6 +282,9 @@ type ServiceB interface {
Entry("option pattern (value, exists bool)",
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
Entry("raw=true binary response",
"raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"),
)
It("generates compilable client code for comprehensive service", func() {

View File

@@ -264,6 +264,96 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
It("should generate binary framing for raw=true methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should include encoding/binary import for raw methods
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
// Should NOT generate a response type for raw methods
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
// Should generate request type (request is still JSON)
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
// Should build binary frame [0x00][4-byte CT len][CT][data]
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
// Should have writeRawError helper
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
// Should use writeRawError instead of writeError for raw methods
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
})
It("should generate both writeError and writeRawError for mixed services", func() {
svc := Service{
Name: "API",
Permission: "api",
Interface: "APIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
{
Name: "CallRaw",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should have both helpers
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
Expect(codeStr).To(ContainSubstring("apiWriteError"))
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
// Should generate response type for non-raw method only
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
})
It("should always include json import for JSON protocol", func() {
// All services use JSON protocol, so json import is always needed
svc := Service{
@@ -626,6 +716,72 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
})
It("should generate binary frame parsing for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
Doc: "GetStream returns raw binary stream data.",
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should import Tuple and struct for raw methods
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
Expect(codeStr).To(ContainSubstring("import struct"))
// Should return Tuple[str, bytes]
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
// Should parse binary frame instead of JSON
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("struct.unpack"))
Expect(codeStr).To(ContainSubstring("return content_type, data"))
// Should NOT use json.loads for response
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
})
It("should not import Tuple or struct for non-raw services", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
Expect(codeStr).NotTo(ContainSubstring("import struct"))
})
})
Describe("GenerateGoDoc", func() {
@@ -782,6 +938,47 @@ var _ = Describe("Generator", func() {
// Check for PDK import
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
})
It("should include encoding/binary import for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
},
},
}
code, err := GenerateClientGo(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should include encoding/binary for raw binary frame parsing
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
// Should NOT generate response type struct for raw methods
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
// Should still generate request type
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
// Should parse binary frame
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
// Should return (string, []byte, error)
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
})
})
Describe("GenerateClientGoStub", func() {
@@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
})
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
svc := Service{
Name: "Stream",
Permission: "stream",
Interface: "StreamService",
Methods: []Method{
{
Name: "GetStream",
HasError: true,
Raw: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{
NewParam("contentType", "string"),
NewParam("data", "[]byte"),
},
Doc: "GetStream returns raw binary stream data.",
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
Expect(codeStr).To(ContainSubstring(`extern "C"`))
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
// Should NOT generate response type for raw methods
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
// Should generate request type (request is still JSON)
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
// Should return Result<(String, Vec<u8>), Error>
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
// Should parse binary frame
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
})
})
})

View File

@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
m := Method{
Name: name,
ExportName: annotation["name"],
Raw: annotation["raw"] == "true",
Doc: doc,
}
@@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
}
}
// Validate raw=true methods: must return exactly (string, []byte, error)
if m.Raw {
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
}
}
return m, nil
}

View File

@@ -122,6 +122,119 @@ type TestService interface {
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
})
It("should parse raw=true annotation", func() {
src := `package host
import "context"
//nd:hostservice name=Stream permission=stream
type StreamService interface {
//nd:hostfunc raw=true
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
m := services[0].Methods[0]
Expect(m.Name).To(Equal("GetStream"))
Expect(m.Raw).To(BeTrue())
Expect(m.HasError).To(BeTrue())
Expect(m.Returns).To(HaveLen(2))
Expect(m.Returns[0].Name).To(Equal("contentType"))
Expect(m.Returns[0].Type).To(Equal("string"))
Expect(m.Returns[1].Name).To(Equal("data"))
Expect(m.Returns[1].Type).To(Equal("[]byte"))
})
It("should set Raw=false when raw annotation is absent", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Call(ctx context.Context, uri string) (response string, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services[0].Methods[0].Raw).To(BeFalse())
})
It("should reject raw=true with invalid return signature", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc raw=true
BadRaw(ctx context.Context, uri string) (result string, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = ParseDirectory(tmpDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("raw=true"))
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
})
It("should reject raw=true without error return", func() {
src := `package host
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc raw=true
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = ParseDirectory(tmpDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("raw=true"))
})
It("should parse mixed raw and non-raw methods", func() {
src := `package host
import "context"
//nd:hostservice name=API permission=api
type APIService interface {
//nd:hostfunc
Call(ctx context.Context, uri string) (responseJSON string, err error)
//nd:hostfunc raw=true
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
}
`
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
Expect(err).NotTo(HaveOccurred())
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
Expect(services[0].Methods).To(HaveLen(2))
Expect(services[0].Methods[0].Raw).To(BeFalse())
Expect(services[0].Methods[1].Raw).To(BeTrue())
Expect(services[0].HasRawMethods()).To(BeTrue())
})
It("should handle custom export name", func() {
src := `package host

View File

@@ -8,6 +8,9 @@
package {{.Package}}
import (
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
{{- if .Service.HasErrors}}
"errors"
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .IsErrorOnly}}
{{- if and (not .IsErrorOnly) (not .Raw)}}
type {{responseType .}} struct {
{{- range .Returns}}
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
{{- if .IsErrorOnly}}
{{- if .Raw}}
// Parse binary-framed response
if len(responseBytes) == 0 {
return "", nil, errors.New("empty response from host")
}
if responseBytes[0] == 0x01 { // error
return "", nil, errors.New(string(responseBytes[1:]))
}
if responseBytes[0] != 0x00 {
return "", nil, errors.New("unknown response status")
}
if len(responseBytes) < 5 {
return "", nil, errors.New("malformed raw response: incomplete header")
}
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
if uint32(len(responseBytes)) < 5+ctLen {
return "", nil, errors.New("malformed raw response: content-type overflow")
}
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
{{- else if .IsErrorOnly}}
// Parse error-only response
var response struct {

View File

@@ -8,10 +8,13 @@
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
import extism
import json
{{- if .Service.HasRawMethods}}
import struct
{{- end}}
class HostFunctionError(Exception):
@@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
{{- end}}
{{- /* Generate dataclasses for multi-value returns */ -}}
{{range .Service.Methods}}
{{- if .NeedsResultClass}}
{{- if and .NeedsResultClass (not .Raw)}}
@dataclass
@@ -44,7 +47,7 @@ class {{pythonResultType .}}:
{{range .Service.Methods}}
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
@@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
{{.PythonName}}: {{.PythonType}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
{{- if .Raw}}
Returns:
Tuple of (content_type, data) with the raw binary response.
{{- else if .HasReturns}}
Returns:
{{- if .NeedsResultClass}}
@@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
request_mem = extism.memory.alloc(request_bytes)
response_offset = _{{exportName .}}(request_mem.offset)
response_mem = extism.memory.find(response_offset)
{{- if .Raw}}
response_bytes = response_mem.bytes()
if len(response_bytes) == 0:
raise HostFunctionError("empty response from host")
if response_bytes[0] == 0x01:
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
if response_bytes[0] != 0x00:
raise HostFunctionError("unknown response status")
if len(response_bytes) < 5:
raise HostFunctionError("malformed raw response: incomplete header")
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
if len(response_bytes) < 5 + ct_len:
raise HostFunctionError("malformed raw response: content-type overflow")
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
data = response_bytes[5 + ct_len:]
return content_type, data
{{- else}}
response = json.loads(extism.memory.string(response_mem))
{{if .HasError}}
if response.get("error"):
@@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}
{{- end}}

View File

@@ -33,6 +33,7 @@ struct {{requestType .}} {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -47,16 +48,92 @@ struct {{responseType .}} {
{{- end}}
}
{{- end}}
{{- end}}
#[host_fn]
extern "ExtismHost" {
{{- range .Service.Methods}}
{{- if not .Raw}}
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
{{- end}}
{{- end}}
}
{{- /* Declare raw extern "C" imports for raw methods */ -}}
{{- range .Service.Methods}}
{{- if .Raw}}
#[link(wasm_import_module = "extism:host/user")]
extern "C" {
fn {{exportName .}}(offset: u64) -> u64;
}
{{- end}}
{{- end}}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
{{- if .Raw}}
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
///
/// # Arguments
{{- range .Params}}
/// * `{{.RustName}}` - {{rustType .}} parameter.
{{- end}}
{{- end}}
///
/// # Returns
/// A tuple of (content_type, data) with the raw binary response.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<u8>), Error> {
{{- if .HasParams}}
let req = {{requestType .}} {
{{- range .Params}}
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
{{- end}}
};
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
{{- else}}
let input_bytes = b"{}".to_vec();
{{- end}}
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
let response_offset = unsafe { {{exportName .}}(input_mem.offset()) };
let response_mem = Memory::find(response_offset)
.ok_or_else(|| Error::msg("empty response from host"))?;
let response_bytes = response_mem.to_vec();
if response_bytes.is_empty() {
return Err(Error::msg("empty response from host"));
}
if response_bytes[0] == 0x01 {
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
return Err(Error::msg(msg));
}
if response_bytes[0] != 0x00 {
return Err(Error::msg("unknown response status"));
}
if response_bytes.len() < 5 {
return Err(Error::msg("malformed raw response: incomplete header"));
}
let ct_len = u32::from_be_bytes([
response_bytes[1],
response_bytes[2],
response_bytes[3],
response_bytes[4],
]) as usize;
if ct_len > response_bytes.len() - 5 {
return Err(Error::msg("malformed raw response: content-type overflow"));
}
let ct_end = 5 + ct_len;
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
let data = response_bytes[ct_end..].to_vec();
Ok((content_type, data))
}
{{- else}}
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
@@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
}
{{- end}}
{{- end}}
{{- end}}

View File

@@ -4,6 +4,9 @@ package {{.Package}}
import (
"context"
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
Error string `json:"error,omitempty"`
{{- end}}
}
{{- end}}
{{end}}
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
@@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
{{- if .Raw}}
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
{{- else}}
{{$.Service.Name | lower}}WriteError(p, stack, err)
{{- end}}
return
}
var req {{requestType .}}
if err := json.Unmarshal(reqBytes, &req); err != nil {
{{- if .Raw}}
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
{{- else}}
{{$.Service.Name | lower}}WriteError(p, stack, err)
{{- end}}
return
}
{{- end}}
// Call the service method
{{- if .HasReturns}}
{{- if .Raw}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
if svcErr != nil {
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
return
}
// Write binary-framed response to plugin memory:
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
ctBytes := []byte({{lower (index .Returns 0).Name}})
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
frame[0] = 0x00 // success
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
copy(frame[5:5+len(ctBytes)], ctBytes)
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
respPtr, err := p.WriteBytes(frame)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
{{- else if .HasReturns}}
{{- if .HasError}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
if svcErr != nil {
@@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- else}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
// Write JSON response to plugin memory
resp := {{responseType .}}{
@@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- end}}
}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- end}}
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
@@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}
{{- if .Service.HasRawMethods}}
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
// Format: [0x01][UTF-8 error message]
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
errMsg := []byte(err.Error())
frame := make([]byte, 1+len(errMsg))
frame[0] = 0x01 // error
copy(frame[1:], errMsg)
respPtr, _ := p.WriteBytes(frame)
stack[0] = respPtr
}
{{- end}}

View File

@@ -173,6 +173,16 @@ func (s Service) HasErrors() bool {
return false
}
// HasRawMethods returns true if any method in the service uses raw binary framing.
func (s Service) HasRawMethods() bool {
for _, m := range s.Methods {
if m.Raw {
return true
}
}
return false
}
// Method represents a host function method within a service.
type Method struct {
Name string // Go method name (e.g., "Call")
@@ -181,6 +191,7 @@ type Method struct {
Returns []Param // Return values (excluding error)
HasError bool // Whether the method returns an error
Doc string // Documentation comment for the method
Raw bool // If true, response uses binary framing instead of JSON
}
// FunctionName returns the Extism host function export name.

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