Compare commits

..

91 Commits

Author SHA1 Message Date
Deluan
b33d831a1d fix: small issues
Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 20:17:31 -05:00
Deluan
3b1bd2c265 feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
0c55c7ce89 fix: address review findings for OpenSubsonic transcoding PR
Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.
2026-02-09 16:46:46 -05:00
Deluan
fab2acfe36 fix: implement noopDecider for transcoding decision handling in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
22dba77509 refactor(transcoding): update default command handling and add codec support for transcoding
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
5107492059 refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
01b1fc90a9 refactor(transcoding): enhance AAC command handling and support for audio channels in streaming
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
4a50142dd6 refactor(transcoding): add bit depth support for audio transcoding and enhance related logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
e843b918b2 refactor(transcoding): enhance transcoding options with sample rate support and improve command handling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
39e341e863 refactor(transcoding): enhance transcoding config lookup logic for audio codecs
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
7ca0eade80 refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
2e02e92cc4 refactor(transcoding): enhance logging for transcode decision process and client info conversion
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
216d0c6c6c refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
c26cc0f5b9 refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
4bb6802922 refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
2e00479a8b feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
07e2f699da fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
ff57efa170 refactor(transcoding): simplify container alias handling in matchesContainer function
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
0658e1f824 fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
a88ab9f16c fix(subsonic): update codec limitation structure and decision logic for improved clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
b5621b9784 feat(subsonic): implement transcode decision logic and codec handling for media files
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46: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
Deluan
15526b25e5 docs: fix gotaglib comment
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-02 17:34:54 -05:00
York
948f6507c1 fix(ui): update Traditional Chinese translation (#4961) 2026-02-02 21:03:34 +01:00
Deluan Quintão
9bce7677f5 fix(ui): update Bulgarian, Catalan, German, Greek, Spanish, Finnish, French, Galician, Dutch, Polish, Portuguese (BR), Russian, Slovenian, Swedish, Thai translations from POEditor (#4852)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-02-02 09:05:28 +01:00
Deluan
7b709899a1 refactor(plugins): simplify websocket callback invocation by creating a generic helper function
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-02 08:59:54 +01:00
Deluan
ebbc31f1ab fix(scanner): store scan errors in the database and update UI error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-01 16:18:26 +01:00
MichaIng
84ab652ca7 feat: add riscv64 builds (#4949)
* ci: add riscv64 builds

This requires at least Debian Trixie base systems, and a cross-taglib version with riscv64 release assets.

Signed-off-by: MichaIng <micha@dietpi.com>

* fix(makefile): add riscv64 to supported platforms and update cross-taglib version

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

---------

Signed-off-by: MichaIng <micha@dietpi.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-01-31 07:24:19 +01:00
Kendall Garner
f13ca58c98 fix(plugins): allow using defaults in config form manifest (#4954) 2026-01-30 15:26:17 +01:00
Deluan Quintão
36252823ce fix(agents): deduplicate mismatched songs in similar songs matching (#4956)
* feat(agents): enhance song matching by removing unwanted duplicates while preserving identical entries

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

* refactor: consolidate duplicate checks

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-30 15:25:00 +01:00
Deluan
7d5e13672d refactor(plugins): remove unnecessary configuration permissions from manifest files
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-29 17:27:16 -05:00
Deluan
4c2bd7509c fix(ui): disable shuffle for instant mix playback
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-29 17:04:10 -05:00
Deluan Quintão
7b523d6b61 feat(agents): support multiple languages for Last.fm and Deezer metadata (#4952)
* feat(lastfm): support multiple languages for album and artist info retrieval

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

* fix(lastfm): improve content validation for album and artist descriptions

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

* refactor(lastfm): remove single language test and clarify languages field in configuration

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

* feat(deezer): support multiple languages for artist bio retrieval

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

* refactor(lastfm): rename ignoredBiographies to ignoredContent for clarity

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-29 13:05:51 -05:00
Deluan
c9e58e3666 feat: enable plugins by default in configuration settings
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-29 12:09:45 -05:00
Deluan
77367548f6 fix(artwork): clamp requested square size to original dimensions for cover art, to avoid upscaling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-28 12:46:46 -05:00
Deluan
71f549afbf fix(configuration): ensure default PIDs are set for Album and Track
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-27 20:15:58 -05:00
Deluan Quintão
1afcf7775b feat: add ISRC matching for similar songs (#4946)
* feat: add ISRC support to similar songs matching and plugin interface

Add ISRC (International Standard Recording Code) as a high-priority
identifier in the provider matching algorithm, alongside MBID. The
matching pipeline now uses four strategies in priority order:
ID > MBID > ISRC > Title+Artist fuzzy match.

- Add ISRC field to agents.Song struct
- Add ISRC field to plugin capability SongRef (Go, Rust PDKs)
- Add loadTracksByISRC using json_tree query on tags column
- Integrate ISRC into matchSongsToLibrary, selectBestMatchingSongs,
  and buildTitleQueries

https://claude.ai/code/session_01Dd4mTq1VQZag4RNjCVusiF

* chore: regenerate plugin schema after ISRC addition

Run `make gen` to update the generated YAML schema for the
metadata agent capability with the new ISRC field on SongRef.

https://claude.ai/code/session_01Dd4mTq1VQZag4RNjCVusiF

* feat(mediafile): add GetAllByTags method to MediaFileRepository for tag-based retrieval

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

* feat(provider): speed up track matching by incorporating prior matches in ISRC and MBID lookups

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-27 14:54:29 -05:00
Deluan
a55c4f0410 fix(plugins): log plugin function not implemented and record successful request metrics
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-27 14:32:57 -05:00
Deluan Quintão
5db585e1b1 refactor: use duration as a soft ranking signal instead of hard cutoff in track matching (#4944)
* refactor: integrate duration into matchScore instead of using pre-filter

Duration matching was handled as a binary pre-filter with fallback,
inconsistent with how title, specificity, and album are scored via the
matchScore system. Move duration into matchScore as a boolean field
ranked between title similarity and specificity level, making all
match criteria use the same hierarchical comparison.

https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs

* refactor: remove findBestMatchInTracks function and integrate its logic into findBestMatch

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

* refactor: use duration proximity score instead of boolean match

Replace the binary durationMatch bool with a continuous durationProximity
float64 (0.0-1.0) using 1/(1+diff). This removes the hard 3-second
tolerance cutoff, so closer durations are always preferred over farther
ones without an arbitrary cliff edge.

https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs

* style: fix gofmt alignment in matchScore struct

https://claude.ai/code/session_01BWJ5aAzbQRvwjB7PvUcNYs

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-27 11:12:18 -05:00
Deluan
63517e904c feat(insights): collect ScannerExtractor configuration to measure gotaglib usage
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-26 20:31:39 -05:00
Deluan
51026de80b fix(lastfm): send parameters in request body for POST requests in scrobble and updateNowPlaying methods
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-26 20:13:04 -05:00
Deluan Quintão
fda35dd8ce feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)
* feat: add duration filtering for similar songs matching

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

* test: refactor expectations for similar songs in provider matching tests

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

* feat(plugins): add functions to retrieve similar songs by track, album, and artist

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

* fix(plugins): support uint32 in ndpgen

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

* fix(plugins): update duration field to use seconds as float instead of milliseconds as uint32

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

* fix: add helper functions for Rust's skip_serializing_if with numeric types

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

* feat(provider): enhance track matching logic to fallback to title match when duration-filtered tracks fail

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-26 18:28:41 -05:00
Deluan
4d4740b83b fix(subsonic): fix support for LegacyClients
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-25 18:06:48 -05:00
Deluan Quintão
772d1f359b feat: add similar songs functionality in agents, and Instant Mix (song-based) to UI (#4919)
* refactor: rename ArtistRadio to SimilarSongs for clarity and consistency

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

* feat: implement GetSimilarSongsByTrack and related functionality for song similarity retrieval

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

* feat: enhance GetSimilarSongsByTrack to include artist and album details and update tests

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

* feat: enhance song matching by implementing title and artist filtering in loadTracksByTitleAndArtist

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

* test: add unit tests for song matching functionality in provider

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

* refactor: extract song matching functionality into its own file

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

* docs: clarify similarSongsFallback function description in provider.go

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

* refactor: initialize result slice for songs with capacity based on response length

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

* refactor: simplify agent method calls for retrieving images and similar songs

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

* refactor: simplify agent method calls for retrieving images and similar songs

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

* refactor: remove outdated comments in GetSimilarSongs methods

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

* fix: use composite key for song matches to handle duplicates by title and artist

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

* refactor: consolidate expectations setup for similar songs tests

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

* feat: add instant mix action to song context menu and update translations

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

* fix(provider): handle unknown entity types in GetSimilarSongs

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

* refactor: move playSimilar action to playbackActions and streamline song processing

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

* format

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

* feat: enhance instant mix functionality with loading notification and shuffle option

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

* feat: implement fuzzy matching for similar songs based on configurable threshold

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

* refactor: implement track matching with multiple specificity levels

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

* refactor: enhance track matching by implementing unified scoring with specificity levels

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

* feat: enhance deezer top tracks result with album

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

* feat: enhance track matching with fuzzy album similarity for improved scoring

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

* docs: document multi-phase song matching algorithm with detailed scoring and prioritization

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-25 16:16:43 -05:00
Deluan Quintão
b455546fdf fix(playlists): better M3U paths matching across different UTF representations (#4890)
* fix: improve playlist path normalization for cross-platform compatibility

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

* fix: log normalized path when playlist path is not found

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

* test: enhance Unicode normalization tests for playlist paths

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

* fix: enhance playlist path normalization for cross-platform compatibility

See https://github.com/navidrome/navidrome/pull/4789#issuecomment-3645724780

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

* fix: improve playlist path normalization to handle fullwidth characters and enhance cross-platform compatibility

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

* formatting

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

* fix: adjust chunk size for M3U parsing to optimize SQLite expression tree depth

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-24 12:47:43 -05:00
Kendall Garner
c6c1c16923 fix(plugin): enable http response headers (#4923) 2026-01-22 05:12:03 -05:00
Deluan Quintão
75dd28678f fix(ui): fine-tune plugins config form (#4916)
* fix(ui): use stock array renderer for plugins config form

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

* fix(plugins): enforce minimum user tokens and require users field

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

* fix(ui): simplify error handling in control state hook

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

* fix(ui): remove "None" MenuItem from OutlinedEnumControl

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

* fix(ui): enhance error handling by returning field info and path in validation errors

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

* fix(ui): update OutlinedEnumControl to handle empty values and remove "None" option when required

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-21 19:25:45 -05:00
Deluan
1c4a7e8556 fix(scanner): prevent infinite recursion in pid configuration
closes #4920

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-21 13:51:30 -05:00
Kendall Garner
b1b488be77 fix(db): Include items with no annotation for starred=false, handle has_rating=false (#4921)
* fix(db): Include items with no annotation for starred=false, handle has_rating=false

* hardcode starred instead

* test: ensure albums and artists without annotations are included in starred and has_rating filters

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

* refactor: replace starred and has_rating filters with annotationBoolFilter for consistency

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

* fix: update annotationBoolFilter to handle boolean values correctly in SQL expressions

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-01-21 13:45:17 -05:00
Deluan
6fce30c133 feat(ui): enhance comment input in PlaylistEdit with multiline support and resizing
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 13:27:10 -05:00
Boris Rorsvort
6c7f8314e2 fix(ui): UI issues & styling coherence (#4910)
* fix: ui issues and styles

* fix linter
2026-01-20 12:45:33 -05:00
Boris Rorsvort
37aa54fe06 feat(ui): Add Nautiline like theme (#4909)
* wip

* add main file

* fixes

* linting

* refactor

* fix player

* fix lint

* fix pr comments

* Add font locally

* fix: quickfix
2026-01-20 12:11:47 -05:00
Deluan
fae58bb390 chore(deps): update Go dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-20 06:51:19 -05:00
Deluan Quintão
f1e75c40dc feat(plugins): add JSONForms-based plugin configuration UI (#4911)
* feat(plugins): add JSONForms schema for plugin configuration

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

* feat: enhance error handling by formatting validation errors with field names

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

* feat: enforce required fields in config validation and improve error handling

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

* format JS code

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

* feat: add config schema validation and enhance manifest structure

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

* feat: refactor plugin config parsing and add unit tests

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

* feat: add config validation error message in Portuguese

* feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing

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

* feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation

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

* fix: resolve React Hooks linting issues in plugin UI components

* Apply suggestions from code review

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

* format code

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

* feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting

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

* address PR comments

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

* fix flaky test

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

* feat: enhance array layout and configuration handling with AJV defaults

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

* feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout

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

* feat: add error boundary for schema rendering and improve error messages

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

* feat: refine non-enum array control logic by utilizing JSONForms schema resolution

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

* feat: add error styling to ToggleEnabledSwitch for disabled state

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

* feat: adjust label positioning and styling in SchemaConfigEditor for improved layout

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

* feat: implement outlined input controls renderers to replace custom fragile CSS

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

* feat: remove margin from last form control inside array items for better spacing

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

* feat: enhance AJV error handling to transform required errors for field-level validation

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

* feat: set default value for User Tokens in manifest.json to improve user experience

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

* format

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

* feat: add margin to outlined input controls for improved spacing

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

* feat: remove redundant margin rule for last form control in array items

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

* feat: adjust font size of label elements in SchemaConfigEditor for improved readability

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-19 20:51:00 -05:00
Deluan
66474fc9f4 feat: add support for reading embedded images using taglib by default
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 22:14:21 -05:00
Deluan
fd620413b8 fix(tests): update goleak check condition to use GOLEAK environment variable
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 21:11:06 -05:00
Deluan
4ec6e7c56e perf(taglib): update taglib to use ReadStyleFast for improved performance
Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 21:10:06 -05:00
Terry Raimondo
03120bac32 feat(subsonic): Add avgRating from subsonic spec (#4900)
* feat(subsonic): add averageRating to API responses

Add averageRating attribute to Subsonic API responses for artists,
albums, and songs. The average is calculated across all user ratings.

* perf(db): add index for average rating queries

Add composite index on (item_id, item_type, rating) to optimize
the correlated subquery used for calculating average ratings.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: add tests for averageRating feature

Add tests for:
- Album.AverageRating calculation in persistence layer
- MediaFile.AverageRating calculation in persistence layer
- AverageRating mapping in subsonic response helpers

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: improve averageRating rounding test with 3 users

Add third test user to fixtures and update rounding test to use
3 ratings (5 + 4 + 4) / 3 = 4.33 for proper decimal rounding coverage.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* perf: store avg_rating on entity tables instead of using subquery

- Add avg_rating column to album, media_file, and artist tables
- Update SetRating() to recalculate and store average when ratings change
- Read avg_rating directly from entity table in withAnnotation()
- Remove old annotation index migration (no longer needed)

This trades write-time computation for read-time performance by
pre-computing the average rating instead of using a correlated
subquery on every read.

* feat: add Subsonic.EnableAverageRating config option (default true)

Allow administrators to disable exposing averageRating in Subsonic API
responses if they don't want to expose other users' rating data.

The avg_rating column is still updated internally when users rate items,
but the value is only included in API responses when this option is enabled.

* address PR comments

- Use structs:"avg_rating" with db:"avg_rating" tag instead of SQL alias
- Remove avg_rating indexes (not needed)
- Populate avg_rating columns from existing ratings in migration

* Woops

* rename avg_rating column to average_rating

---------

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>
2026-01-18 17:42:42 -05:00
Deluan
0473c50b49 feat(insights): add file suffix counting 2026-01-18 17:00:35 -05:00
Deluan Quintão
2de2484bca feat: add go-taglib pure Go metadata extractor (#4902)
* feat: implement go-taglib extractor

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

* feat: enhance ID3v2 frame parsing for language-specific lyrics

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

* feat: add support for reading iTunes-specific tags from M4A files

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

* feat: expose BitDepth in AudioProperties struct

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

* feat: enhance WMA tag parsing by adding support for ASF attributes

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

* feat: enhance ID3v2 frame parsing for WAV and AIFF formats to support language codes

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

* chore: usa a ignored go.work for local dependency management

* feat: optimize metadata extraction by consolidating file reads and improving tag processing

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

* remove comment

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

* feat: improve language code extraction for lyrics tags in metadata processing

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

* address PR comments

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

* chore: remove outdated comments in gotaglib.go

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

* feat: enhance extractor to utilize filesystem for file handling

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

* chore: update go-taglib dependency version in go.mod and go.sum

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

* feat: make new go-taglib extractor default

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

* chore: formatting

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 14:42:53 -05:00
Albert Brugués
64e165aaef fix(ui): update Spanish translations (#4904)
* update spanish translations

* fix typo in word Arreglistas

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

* fix missing pipe char

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

* fix invalidJson value

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

* fix click translation in clickPermissions key

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

* fix remove_missing_title value

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

* fix remove_all_missing_title value

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

* fix missing accent

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

* fix missing accents

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

* fix disabled translation

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-01-18 13:28:40 -05:00
Alex Gustafsson
8e96dd0784 feat(ui): add composer field to table views (#4857)
* feat(ui): Add composer field to datatables

In order to make the UI a bit more useful for classical music, where the
recorded artist isn't the composer of the work, add the composer field
to the song and album datatables.

To not affect existing users, the field is default off.

* Fix typo

* Remove composer field for albums

Albums can have more than one composer. Showing all or just one of them
in a list doesn't really make sense.

* Format code
2026-01-18 13:15:53 -05:00
Alanna Tempest
9bd91d2c04 feat(ui): prompt before closing window if music is playing (#4899)
* feat(ui): prompt before closing window if music is playing - #4898

* simplify logic
2026-01-18 13:11:12 -05:00
362 changed files with 23701 additions and 5816 deletions

View File

@@ -23,5 +23,7 @@ RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& rmdir /usr/include/taglib \ && rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json && rm /tmp/cross-taglib.tar.gz /usr/provenance.json
ENV CGO_CFLAGS_ALLOW="--define-prefix"
# [Optional] Uncomment this line to install global node packages. # [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1 # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

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

View File

@@ -14,7 +14,8 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
CROSS_TAGLIB_VERSION: "2.1.1-1" CROSS_TAGLIB_VERSION: "2.1.1-2"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs: jobs:
@@ -192,7 +193,7 @@ jobs:
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled] needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy: strategy:
matrix: matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }} IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}

2
.gitignore vendored
View File

@@ -20,6 +20,7 @@ cache/*
coverage.out coverage.out
dist dist
music music
music.old
*.db* *.db*
.gitinfo .gitinfo
docker-compose.yml docker-compose.yml
@@ -36,3 +37,4 @@ AGENTS.md
*.wasm *.wasm
*.ndp *.ndp
openspec/ openspec/
go.work*

View File

@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib ### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1 ARG CROSS_TAGLIB_VERSION=2.1.1-2
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects # wget in busybox can't follow redirects
@@ -63,7 +63,7 @@ COPY --from=ui /build /build
######################################################################################################################## ########################################################################################################################
### Build Navidrome binary ### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
RUN apt-get update && apt-get install -y clang lld RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / / COPY --from=xx / /
WORKDIR /workspace WORKDIR /workspace
@@ -94,6 +94,7 @@ RUN --mount=type=bind,source=. \
# Setup CGO cross-compilation environment # Setup CGO cross-compilation environment
xx-go --wrap xx-go --wrap
export CGO_ENABLED=1 export CGO_ENABLED=1
export CGO_CFLAGS_ALLOW="--define-prefix"
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV) cat $(go env GOENV)

View File

@@ -2,6 +2,7 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc) NODE_VERSION=$(shell cat .nvmrc)
# Set global environment variables, required for most targets # Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
export ND_ENABLEINSIGHTSCOLLECTOR=false export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","") ifneq ("$(wildcard .git/HEAD)","")
@@ -12,13 +13,13 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386 SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS) PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib # Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1 CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.8.0 GOLANGCI_LINT_VERSION ?= v2.8.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@@ -29,14 +29,12 @@ type httpDoer interface {
type client struct { type client struct {
httpDoer httpDoer httpDoer httpDoer
language string
jwt jwtToken jwt jwtToken
} }
func newClient(hc httpDoer, language string) *client { func newClient(hc httpDoer) *client {
return &client{ return &client{
httpDoer: hc, httpDoer: hc,
language: language,
} }
} }
@@ -129,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy() var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) { func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
jwt, err := c.getJWT(ctx) jwt, err := c.getJWT(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err) return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
@@ -160,10 +158,10 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language) req.Header.Set("Accept-Language", lang)
req.Header.Set("Authorization", "Bearer "+jwt) req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language) log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
resp, err := c.httpDoer.Do(req) resp, err := c.httpDoer.Do(req)
if err != nil { if err != nil {
return "", err return "", err

View File

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

View File

@@ -18,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &fakeHttpClient{} httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en") client = newClient(httpClient)
}) })
Describe("ArtistImages", func() { Describe("ArtistImages", func() {
@@ -45,6 +45,28 @@ var _ = Describe("client", func() {
}) })
}) })
Describe("TopTracks", func() {
It("returns top tracks with artist and album info from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
Expect(err).To(BeNil())
Expect(tracks).To(HaveLen(5))
// Verify first track has all expected fields
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
// Verify second track
Expect(tracks[1].Title).To(Equal("One More Time"))
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
})
})
Describe("ArtistBio", func() { Describe("ArtistBio", func() {
BeforeEach(func() { BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
@@ -56,40 +78,33 @@ var _ = Describe("client", func() {
}) })
It("returns artist bio from a successful request", func() { It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json") f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27) bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>")) Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>")) Expect(bio).ToNot(ContainSubstring("</p>"))
}) })
It("uses the configured language", func() { It("uses the provided language", func() {
client = newClient(httpClient, "fr") f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
// Mock JWT token for the new client instance with a valid JWT
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27) _, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
}) })
It("includes the JWT token in the request", func() { It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json") f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27) _, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format // Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization") authHeader := httpClient.lastRequest.Header.Get("Authorization")
@@ -120,7 +135,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(errorResponse)), Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
}) })
_, err := client.getArtistBio(GinkgoT().Context(), 999) _, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error")) Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found")) Expect(err.Error()).To(ContainSubstring("Artist not found"))
@@ -142,7 +157,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
}) })
_, err := client.getArtistBio(GinkgoT().Context(), 27) _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(MatchError("deezer: biography not found")) Expect(err).To(MatchError("deezer: biography not found"))
}) })
@@ -152,7 +167,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
}) })
_, err := client.getArtistBio(GinkgoT().Context(), 27) _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT")) Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
}) })
@@ -165,7 +180,7 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
}) })
_, err := client.getArtistBio(GinkgoT().Context(), 27) _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
}) })

View File

@@ -26,15 +26,19 @@ const deezerArtistSearchLimit = 50
type deezerAgent struct { type deezerAgent struct {
dataStore model.DataStore dataStore model.DataStore
client *client client *client
languages []string
} }
func deezerConstructor(dataStore model.DataStore) agents.Interface { func deezerConstructor(dataStore model.DataStore) agents.Interface {
agent := &deezerAgent{dataStore: dataStore} agent := &deezerAgent{
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := &http.Client{ httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut, Timeout: consts.DefaultHttpClientTimeOut,
} }
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) agent.client = newClient(cachedHttpClient)
return agent return agent
} }
@@ -135,7 +139,9 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
res := slice.Map(tracks, func(r Track) agents.Song { res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{ return agents.Song{
Name: r.Title, Name: r.Title,
Album: r.Album.Title,
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
} }
}) })
return res, nil return res, nil
@@ -147,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
return "", err return "", err
} }
return s.client.getArtistBio(ctx, artist.ID) for _, lang := range s.languages {
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
if err == nil && bio != "" {
return bio, nil
}
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
}
return "", agents.ErrNotFound
} }
func init() { func init() {

View File

@@ -0,0 +1,171 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("deezerAgent", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.Deezer.Enabled = true
})
Describe("deezerConstructor", func() {
It("uses configured languages", func() {
conf.Server.Deezer.Languages = []string{"pt", "en"}
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
// Mock search artist (returns Michael Jackson)
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
// Mock JWT token
testJWT := createTestJWT(5 * time.Minute)
httpClient.jwtResponse = &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
}
})
setupAgent := func(languages []string) {
conf.Server.Deezer.Languages = languages
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
languages: languages,
}
}
It("returns content in first language when available (1 bio API call)", func() {
setupAgent([]string{"fr", "en"})
// French biography available
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
Expect(httpClient.bioRequestCount).To(Equal(1))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
})
It("falls back to second language when first returns empty (2 bio API calls)", func() {
setupAgent([]string{"ja", "en"})
// Japanese returns empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(httpClient.bioRequestCount).To(Equal(2))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
setupAgent([]string{"ja", "xx"})
// Both languages return empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.bioRequestCount).To(Equal(2))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
type langAwareHttpClient struct {
searchResponse *http.Response
jwtResponse *http.Response
bioResponses map[string]*http.Response
bioRequests []*http.Request
bioRequestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
bioResponses: make(map[string]*http.Response),
bioRequests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
// Handle search artist request
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
if c.searchResponse != nil {
return c.searchResponse, nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
}, nil
}
// Handle JWT token request
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
if c.jwtResponse != nil {
return c.jwtResponse, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
}, nil
}
// Handle bio request (GraphQL API)
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
c.bioRequestCount++
c.bioRequests = append(c.bioRequests, req)
lang := req.Header.Get("Accept-Language")
if resp, ok := c.bioResponses[lang]; ok {
return resp, nil
}
// Return empty bio by default
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
}, nil
}
panic("URL not mocked: " + req.URL.String())
}

View File

@@ -0,0 +1,274 @@
package gotaglib
import (
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
parseTestFile := func(path string) *model.MediaFile {
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info, ok := mds[path]
Expect(ok).To(BeTrue())
fileInfo, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
}
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("ReplayGain", func() {
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
mf := parseTestFile("tests/fixtures/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
Expect(mf.RGAlbumGain).To(Equal(albumGain))
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
)
})
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
}
}
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(2))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[1].Synced).To(BeFalse())
})
It("should handle mp3 with uslt and sylt", func() {
mf := parseTestFile("tests/fixtures/test.mp3")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(4))
engSylt := makeLyrics("eng", "English SYLT")
engUslt := makeLyrics("eng", "English")
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
mf := parseTestFile("tests/fixtures/" + file)
lyrics, err := mf.StructuredLyrics()
Expect(err).To(Not(HaveOccurred()))
Expect(lyrics).To(HaveLen(2))
unspec := makeLyrics("xxx", "unspecified")
eng := makeLyrics("xxx", "English")
if isId3 {
eng.Lang = "eng"
}
Expect(lyrics).To(Or(
Equal(model.LyricList{unspec, eng}),
Equal(model.LyricList{eng, unspec})))
},
Entry("flac", "test.flac", false),
Entry("m4a", "test.m4a", false),
Entry("ogg", "test.ogg", false),
Entry("wma", "test.wma", false),
Entry("wv", "test.wv", false),
Entry("wav", "test.wav", true),
Entry("aiff", "test.aiff", true),
)
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WV format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
It("should parse wma", func() {
mf := parseTestFile("tests/fixtures/test.wma")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
// WMA has no Arranger role
if role == model.RoleArranger {
Expect(actual).To(HaveLen(0))
continue
}
Expect(actual).To(HaveLen(len(artists)), role.String())
// For some bizarre reason, the order is inverted. We also don't get
// sort names or MBIDs
for i := range artists {
idx := len(artists) - 1 - i
actualArtist := actual[i]
expectedArtist := artists[idx]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
}
}
})
})
})

View File

@@ -0,0 +1,283 @@
// Package gotaglib provides an alternative metadata extractor using go-taglib,
// a pure Go (WASM-based) implementation of TagLib.
//
// This extractor aims for parity with the CGO-based taglib extractor. It uses
// TagLib's PropertyMap interface for standard tags. The File handle API provides
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
// through a single file open operation.
//
// This extractor is registered under the name "taglib". It only works with a filesystem
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
// must implement io.ReadSeeker for go-taglib to read them.
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"
)
type extractor struct {
fs fs.FS
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return "go-taglib (TagLib 2.1.1 WASM)"
}
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()
// Get all tags and properties in one go
allTags := f.AllTags()
props := f.Properties()
// Map properties to AudioProperties
ap := metadata.AudioProperties{
Duration: props.Length.Round(time.Millisecond * 10),
BitRate: int(props.Bitrate),
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
normalizedTags := make(map[string][]string, len(allTags.Tags))
for key, values := range allTags.Tags {
lowerKey := strings.ToLower(key)
normalizedTags[lowerKey] = values
}
// Process format-specific raw tags
processRawTags(allTags, normalizedTags)
// Parse track/disc totals from "N/Total" format
parseTuple(normalizedTags, "track")
parseTuple(normalizedTags, "disc")
// Adjust some ID3 tags
parseLyrics(normalizedTags)
parseTIPL(normalizedTags)
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
// Determine if file has embedded picture
hasPicture := len(props.Images) > 0
return &metadata.Info{
Tags: normalizedTags,
AudioProperties: ap,
HasPicture: hasPicture,
}, nil
}
// 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) (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 {
return nil, nil, err
}
rs, isSeekable := file.(io.ReadSeeker)
if !isSeekable {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
// 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() {
f.Close()
file.Close()
}
return f, closeFunc, nil
}
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
func parseTuple(tags map[string][]string, prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
// parseLyrics ensures lyrics tags have a language code.
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// processRawTags processes format-specific raw tags based on the detected file format.
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
switch allTags.Format {
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
parseID3v2Frames(allTags.Raw, normalizedTags)
case taglib.FormatMP4:
parseMP4Atoms(allTags.Raw, normalizedTags)
case taglib.FormatASF:
parseASFAttributes(allTags.Raw, normalizedTags)
}
}
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
// Process frames that have language-specific data
for key, values := range rawFrames {
lowerKey := strings.ToLower(key)
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
parts := strings.SplitN(lowerKey, ":", 2)
if len(parts) == 2 && parts[1] != "" {
lang := parts[1]
lyricsKey := "lyrics:" + lang
tags[lyricsKey] = append(tags[lyricsKey], values...)
}
}
}
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
for key := range tags {
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
delete(tags, "lyrics")
break
}
}
}
const iTunesKeyPrefix = "----:com.apple.iTunes:"
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
// Process all atoms and add them to tags
for key, values := range rawAtoms {
// Strip iTunes prefix and convert to lowercase
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
normalizedKey = strings.ToLower(normalizedKey)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
// Process all attributes and add them to tags
for key, values := range rawAttrs {
normalizedKey := strings.ToLower(key)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
}

View File

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

View File

@@ -0,0 +1,305 @@
package gotaglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
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),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
// Strip leading slash for DirFS rooted at "/"
_, err := e.extractMetadata(accessForbiddenFile[1:])
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
// Get current working directory to construct paths relative to root
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
// Strip leading slash for DirFS rooted at "/"
files := []string{
cwd[1:] + "/tests/fixtures/test.mp3",
cwd[1:] + "/tests/fixtures/test.ogg",
accessForbiddenFile[1:],
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})

View File

@@ -26,17 +26,23 @@ const (
sessionKeyProperty = "LastFMSessionKey" sessionKeyProperty = "LastFMSessionKey"
) )
var ignoredBiographies = []string{ var ignoredContent = []string{
// Unknown Artist // Empty Artist/Album
`<a href="https://www.last.fm/music/`, `<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 { type lastfmAgent struct {
ds model.DataStore ds model.DataStore
sessionKeys *agents.SessionKeys sessionKeys *agents.SessionKeys
apiKey string apiKey string
secret string secret string
lang string languages []string
client *client client *client
httpClient httpDoer httpClient httpDoer
getInfoMutex sync.Mutex getInfoMutex sync.Mutex
@@ -48,7 +54,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
} }
l := &lastfmAgent{ l := &lastfmAgent{
ds: ds, ds: ds,
lang: conf.Server.LastFM.Language, languages: conf.Server.LastFM.Languages,
apiKey: conf.Server.LastFM.ApiKey, apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret, secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
@@ -58,7 +64,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
} }
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc) l.client = newClient(l.apiKey, l.secret, chc)
return l return l
} }
@@ -68,22 +74,47 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`) var imageRegex = regexp.MustCompile(`u\/(\d+)`)
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { // isValidContent checks if content is non-empty and not in the ignored list
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) func isValidContent(content string) bool {
if err != nil { content = strings.TrimSpace(content)
return nil, err if content == "" {
return false
} }
for _, ign := range ignoredContent {
if strings.HasPrefix(content, ign) {
return false
}
}
return true
}
return &agents.AlbumInfo{ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
Name: a.Name, var a *Album
MBID: a.MBID, var resp agents.AlbumInfo
Description: a.Description.Summary, for _, lang := range l.languages {
URL: a.URL, var err error
}, nil a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
if err != nil {
return nil, err
}
resp.Name = a.Name
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(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)
}
// This condition should not be hit (languages default to ["en"]), but just in case
if a == nil {
return nil, agents.ErrNotFound
}
return &resp, nil
} }
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -118,7 +149,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
} }
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name) a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -129,7 +160,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
} }
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name) a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -140,20 +171,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
} }
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name) for _, lang := range l.languages {
if err != nil { a, err := l.callArtistGetInfo(ctx, name, lang)
return "", err if err != nil {
} return "", err
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
if a.Bio.Summary == "" {
return "", agents.ErrNotFound
}
for _, ign := range ignoredBiographies {
if strings.HasPrefix(a.Bio.Summary, ign) {
return "", nil
} }
if isValidContent(a.Bio.Summary) {
return cleanContent(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
} }
return a.Bio.Summary, nil return "", agents.ErrNotFound
} }
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
@@ -192,6 +220,26 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil return res, nil
} }
func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, 0, len(resp))
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
Artist: t.Artist.Name,
ArtistMBID: t.Artist.MBID,
})
}
return res, nil
}
var ( var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
@@ -199,7 +247,7 @@ var (
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name) log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
a, err := l.callArtistGetInfo(ctx, name) a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil { if err != nil {
return nil, fmt.Errorf("get artist info: %w", err) return nil, fmt.Errorf("get artist info: %w", err)
} }
@@ -239,14 +287,14 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil return res, nil
} }
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid) a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
var lfErr *lastFMError var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr) isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) { if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "") return l.callAlbumGetInfo(ctx, name, artist, "", lang)
} }
if err != nil { if err != nil {
@@ -260,11 +308,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil return a, nil
} }
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) { func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
l.getInfoMutex.Lock() l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock() defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name) a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil { if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err return nil, err
@@ -290,6 +338,15 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil return t.Track, nil
} }
func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
s, err := l.client.trackGetSimilar(ctx, name, artist, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
return nil, err
}
return s.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string { func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 { if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name return track.Participants[role][0].Name

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv" "strconv"
"time" "time"
@@ -38,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
}) })
Describe("lastFMConstructor", func() { Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() { When("Agent is properly configured", func() {
It("uses configured api key and language", func() { It("uses configured api key and languages", func() {
conf.Server.LastFM.Language = "pt" conf.Server.LastFM.Languages = []string{"pt", "en"}
agent := lastFMConstructor(ds) agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123")) Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret")) Expect(agent.secret).To(Equal("secret"))
Expect(agent.lang).To(Equal("pt")) Expect(agent.languages).To(Equal([]string{"pt", "en"}))
}) })
}) })
When("Agent is disabled", func() { When("Agent is disabled", func() {
@@ -71,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -79,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() { It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} 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.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
}) })
@@ -101,12 +102,129 @@ var _ = Describe("lastfmAgent", func() {
}) })
}) })
Describe("Language Fallback", func() {
Describe("GetArtistBiography", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("returns content in first language when available (1 API call)", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Portuguese biography available
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
Expect(httpClient.requestCount).To(Equal(1))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
})
It("falls back to second language when first returns empty (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return empty/ignored biography (using actual Last.fm response format)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// Second language also returns empty
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.requestCount).To(Equal(2))
})
})
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("falls back to second language when first returns empty description (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns album without wiki/description (actual Last.fm response)
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns album with description
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns album without description when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return album without description
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(BeEmpty())
Expect(httpClient.requestCount).To(Equal(2))
})
})
})
Describe("GetSimilarArtists", func() { Describe("GetSimilarArtists", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -144,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -177,6 +295,54 @@ var _ = Describe("lastfmAgent", func() {
}) })
}) })
Describe("GetSimilarSongsByTrack", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns similar songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
})
It("returns ErrNotFound when no similar songs found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
})
Describe("Scrobbling", func() { Describe("Scrobbling", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
@@ -184,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "en", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
track = &model.MediaFile{ track = &model.MediaFile{
@@ -217,7 +383,8 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
sentParams := httpClient.SavedRequest.URL.Query() body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying")) Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(sentParams.Get("sk")).To(Equal("SK-1")) Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title)) Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -245,7 +412,8 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-1", track, 0) err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query() body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("artist")).To(Equal("First Artist")) Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
}) })
@@ -261,7 +429,8 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
sentParams := httpClient.SavedRequest.URL.Query() body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.scrobble")) Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
Expect(sentParams.Get("sk")).To(Equal("SK-1")) Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title)) Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -286,7 +455,8 @@ var _ = Describe("lastfmAgent", func() {
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query() body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("artist")).To(Equal("First Artist")) Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
}) })
@@ -354,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient) client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@@ -365,7 +535,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{ Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe", Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", 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", URL: "https://www.last.fm/music/Cher/Believe",
})) }))
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
@@ -424,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
apiClient = &tests.FakeHttpClient{} apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", apiClient) client := newClient("API_KEY", "SECRET", apiClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
agent.httpClient = httpClient agent.httpClient = httpClient
@@ -485,3 +655,31 @@ var _ = Describe("lastfmAgent", func() {
}) })
}) })
}) })
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
type langAwareHttpClient struct {
responses map[string]http.Response
requests []*http.Request
requestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
responses: make(map[string]http.Response),
requests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
c.requestCount++
c.requests = append(c.requests, req)
lang := req.URL.Query().Get("lang")
if resp, ok := c.responses[lang]; ok {
return &resp, nil
}
// Return default empty response if no specific response is configured
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
}, nil
}

View File

@@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router {
hc := &http.Client{ hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut, Timeout: consts.DefaultHttpClientTimeOut,
} }
r.client = newClient(r.apiKey, r.secret, "en", hc) r.client = newClient(r.apiKey, r.secret, hc)
return r return r
} }
@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
} }
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{ resp := map[string]any{
"apiKey": s.apiKey, "apiKey": s.apiKey,
} }
u, _ := request.UserFrom(r.Context()) u, _ := request.UserFrom(r.Context())

View File

@@ -34,24 +34,23 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
} }
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { func newClient(apiKey string, secret string, hc httpDoer) *client {
return &client{apiKey, secret, lang, hc} return &client{apiKey, secret, hc}
} }
type client struct { type client struct {
apiKey string apiKey string
secret string secret string
lang string
hc httpDoer hc httpDoer
} }
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) { func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
params := url.Values{} params := url.Values{}
params.Add("method", "album.getInfo") params.Add("method", "album.getInfo")
params.Add("album", name) params.Add("album", name)
params.Add("artist", artist) params.Add("artist", artist)
params.Add("mbid", mbid) params.Add("mbid", mbid)
params.Add("lang", c.lang) params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false) response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil return &response.Album, nil
} }
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) { func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
params := url.Values{} params := url.Values{}
params.Add("method", "artist.getInfo") params.Add("method", "artist.getInfo")
params.Add("artist", name) params.Add("artist", name)
params.Add("lang", c.lang) params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false) response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -95,6 +94,19 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
return &response.TopTracks, nil return &response.TopTracks, nil
} }
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("track", name)
params.Add("artist", artist)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
}
return &response.SimilarTracks, nil
}
func (c *client) GetToken(ctx context.Context) (string, error) { func (c *client) GetToken(ctx context.Context) (string, error) {
params := url.Values{} params := url.Values{}
params.Add("method", "auth.getToken") params.Add("method", "auth.getToken")
@@ -185,8 +197,15 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
c.sign(params) c.sign(params)
} }
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) var req *http.Request
req.URL.RawQuery = params.Encode() if method == http.MethodPost {
body := strings.NewReader(params.Encode())
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
req.URL.RawQuery = params.Encode()
}
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL) log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req) resp, err := c.hc.Do(req)

View File

@@ -22,7 +22,7 @@ var _ = Describe("client", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", "pt", httpClient) client = newClient("API_KEY", "SECRET", httpClient)
}) })
Describe("albumGetInfo", func() { Describe("albumGetInfo", func() {
@@ -30,7 +30,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234") album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe")) Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
@@ -42,7 +42,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
artist, err := client.artistGetInfo(context.Background(), "U2") artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2")) Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
@@ -54,7 +54,7 @@ var _ = Describe("client", func() {
StatusCode: 500, StatusCode: 500,
} }
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("last.fm http status: (500)")) Expect(err).To(MatchError("last.fm http status: (500)"))
}) })
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400, StatusCode: 400,
} }
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
}) })
@@ -74,14 +74,14 @@ var _ = Describe("client", func() {
StatusCode: 200, StatusCode: 200,
} }
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
}) })
It("fails if HttpClient.Do() returns error", func() { It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error") httpClient.Err = errors.New("generic error")
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("generic error")) Expect(err).To(MatchError("generic error"))
}) })
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200, StatusCode: 200,
} }
_, err := client.artistGetInfo(context.Background(), "U2") _, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value")) Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
}) })
@@ -121,6 +121,30 @@ var _ = Describe("client", func() {
}) })
}) })
Describe("trackGetSimilar", func() {
It("returns similar tracks for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
Expect(err).To(BeNil())
Expect(len(similar.Track)).To(Equal(5))
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
Expect(similar.Track[0].Match).To(Equal(1.0))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
Expect(err).To(BeNil())
Expect(similar.Track).To(BeEmpty())
})
})
Describe("GetToken", func() { Describe("GetToken", func() {
It("returns a token when the request is successful", func() { It("returns a token when the request is successful", func() {
httpClient.Res = http.Response{ httpClient.Res = http.Response{
@@ -154,6 +178,74 @@ var _ = Describe("client", func() {
}) })
}) })
Describe("scrobble", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.scrobble(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("updateNowPlaying", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("sign", func() { Describe("sign", func() {
It("adds an api_sig param with the signature", func() { It("adds an api_sig param with the signature", func() {
params := url.Values{} params := url.Values{}

View File

@@ -5,6 +5,7 @@ type Response struct {
SimilarArtists SimilarArtists `json:"similarartists"` SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"` TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"` Album Album `json:"album"`
SimilarTracks SimilarTracks `json:"similartracks"`
Error int `json:"error"` Error int `json:"error"`
Message string `json:"message"` Message string `json:"message"`
Token string `json:"token"` Token string `json:"token"`
@@ -59,6 +60,28 @@ type TopTracks struct {
Attr Attr `json:"@attr"` Attr Attr `json:"@attr"`
} }
type SimilarTracks struct {
Track []SimilarTrack `json:"track"`
Attr SimilarAttr `json:"@attr"`
}
type SimilarTrack struct {
Name string `json:"name"`
MBID string `json:"mbid"`
Match float64 `json:"match"`
Artist SimilarTrackArtist `json:"artist"`
}
type SimilarTrackArtist struct {
Name string `json:"name"`
MBID string `json:"mbid"`
}
type SimilarAttr struct {
Artist string `json:"artist"`
Track string `json:"track"`
}
type Session struct { type Session struct {
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key"` Key string `json:"key"`

View File

@@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != "" 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() { func init() {
conf.AddHook(func() { conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled { if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { 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" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"os"
"time" "time"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
@@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) 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) { func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{} resp := map[string]any{}
u, _ := request.UserFrom(r.Context()) u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID) key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) { if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return 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) { 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) req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req) r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK)) 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(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false)) Expect(parsed["status"]).To(Equal(false))
}) })
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil) req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req) r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK)) 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(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true)) 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"}`)) req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req) r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK)) 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(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true)) Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser")) Expect(parsed["user"]).To(Equal("ListenBrainzUser"))

View File

@@ -2,16 +2,29 @@ package listenbrainz
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "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 { type listenBrainzError struct {
Code int Code int
Message string Message string
@@ -62,14 +75,14 @@ const (
type listenInfo struct { type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"` ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata,omitempty"` TrackMetadata trackMetadata `json:"track_metadata"`
} }
type trackMetadata struct { type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"` TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"` ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info,omitempty"` AdditionalInfo additionalInfo `json:"additional_info"`
} }
type additionalInfo struct { type additionalInfo struct {
@@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
r := &listenBrainzRequest{ r := &listenBrainzRequest{
ApiKey: apiKey, 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 { if err != nil {
return nil, err 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 { if err != nil {
return err return err
} }
@@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
Payload: []listenInfo{li}, 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 { if err != nil {
return err return err
} }
@@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) {
return u.String(), nil 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) b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint) uri, err := c.path(endpoint)
if err != nil { if err != nil {
@@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
return &response, nil 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" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "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 auth := c.id + ":" + c.secret
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
response := map[string]interface{}{} response := map[string]any{}
err := c.makeRequest(req, &response) err := c.makeRequest(req, &response)
if err != nil { if err != nil {
return "", err return "", err
@@ -86,7 +86,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
return "", errors.New("invalid response") 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) log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req) resp, err := c.hc.Do(req)
if err != nil { if err != nil {

View File

@@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() {
unsSylt := makeLyrics("xxx", "unspecified SYLT") unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified") unsUslt := makeLyrics("xxx", "unspecified")
// Why is the order inconsistent between runs? Nobody knows Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
Expect(lyrics).To(Or(
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
))
}) })
DescribeTable("format-specific lyrics", func(file string, isId3 bool) { DescribeTable("format-specific lyrics", func(file string, isId3 bool) {

View File

@@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) {
var _ local.Extractor = (*extractor)(nil) var _ local.Extractor = (*extractor)(nil)
func init() { func init() {
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor { local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files // ignores fs, as taglib extractor only works with local files
return &extractor{baseDir} return &extractor{baseDir}
}) })

View File

@@ -80,12 +80,11 @@ var _ = Describe("Extractor", func() {
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TabLib 1.12 returns 18, previous versions return 39. // TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49)) Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2)) Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue()) Expect(m.HasPicture).To(BeTrue())
}) })
@@ -106,7 +105,7 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(Or( Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}), HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}), HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
)) ))
Expect(m.Tags).To(Or( Expect(m.Tags).To(Or(

View File

@@ -1,7 +1,8 @@
package taglib package taglib
/* /*
#cgo pkg-config: taglib #cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile #cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11 #cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib #cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib

View File

@@ -24,6 +24,7 @@ import (
// Import adapters to register them // Import adapters to register them
_ "github.com/navidrome/navidrome/adapters/deezer" _ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify" _ "github.com/navidrome/navidrome/adapters/spotify"

View File

@@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
@@ -33,6 +34,7 @@ import (
import ( import (
_ "github.com/navidrome/navidrome/adapters/deezer" _ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify" _ "github.com/navidrome/navidrome/adapters/spotify"
@@ -101,7 +103,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore) playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) decider := transcode.NewDecider(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
return router return router
} }

View File

@@ -1,11 +1,13 @@
package conf package conf
import ( import (
"cmp"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"time" "time"
@@ -57,6 +59,7 @@ type configOptions struct {
AutoTranscodeDownload bool AutoTranscodeDownload bool
DefaultDownsamplingFormat string DefaultDownsamplingFormat string
SearchFullString bool SearchFullString bool
SimilarSongsMatchThreshold int
RecentlyAddedByModTime bool RecentlyAddedByModTime bool
PreferSortTags bool PreferSortTags bool
IgnoredArticles string IgnoredArticles string
@@ -126,6 +129,7 @@ type configOptions struct {
DevExternalScanner bool DevExternalScanner bool
DevScannerThreads uint DevScannerThreads uint
DevSelectiveWatcher bool DevSelectiveWatcher bool
DevLegacyEmbedImage bool
DevInsightsInitialDelay time.Duration DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool DevEnablePlayerInsights bool
DevEnablePluginsInsights bool DevEnablePluginsInsights bool
@@ -152,6 +156,7 @@ type subsonicOptions struct {
AppendSubtitle bool AppendSubtitle bool
ArtistParticipations bool ArtistParticipations bool
DefaultReportRealPath bool DefaultReportRealPath bool
EnableAverageRating bool
LegacyClients string LegacyClients string
MinimalClients string MinimalClients string
} }
@@ -171,6 +176,9 @@ type lastfmOptions struct {
Secret string Secret string
Language string Language string
ScrobbleFirstArtistOnly bool ScrobbleFirstArtistOnly bool
// Computed values
Languages []string // Computed from Language, split by comma
} }
type spotifyOptions struct { type spotifyOptions struct {
@@ -181,11 +189,16 @@ type spotifyOptions struct {
type deezerOptions struct { type deezerOptions struct {
Enabled bool Enabled bool
Language string Language string
// Computed values
Languages []string // Computed from Language, split by comma
} }
type listenBrainzOptions struct { type listenBrainzOptions struct {
Enabled bool Enabled bool
BaseURL string BaseURL string
ArtistAlgorithm string
TrackAlgorithm string
} }
type httpHeaderOptions struct { type httpHeaderOptions struct {
@@ -366,10 +379,16 @@ func Load(noConfigDump bool) {
disableExternalServices() disableExternalServices()
} }
if Server.Scanner.Extractor != consts.DefaultScannerExtractor { // Make sure we don't have empty PIDs
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor)) Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
Server.Scanner.Extractor = consts.DefaultScannerExtractor Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
}
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
logDeprecatedOptions("Scanner.GenreSeparators", "") logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "") logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
@@ -415,7 +434,7 @@ func mapDeprecatedOption(legacyName, newName string) {
func parseIniFileConfiguration() { func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed() cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" { if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{} var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig) err := viper.Unmarshal(&iniConfig)
if err != nil { if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -448,7 +467,7 @@ func disableExternalServices() {
} }
func validatePlaylistsPath() error { 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, "") _, err := doublestar.Match(path, "")
if err != nil { if err != nil {
log.Error("Invalid PlaylistsPath", "path", path, err) log.Error("Invalid PlaylistsPath", "path", path, err)
@@ -458,15 +477,25 @@ func validatePlaylistsPath() error {
return nil return nil
} }
func validatePurgeMissingOption() error { // parseLanguages parses a comma-separated language string into a slice.
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} // It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
valid := false func parseLanguages(lang string) []string {
for _, v := range allowedValues { var languages []string
if v == Server.Scanner.PurgeMissing { for l := range strings.SplitSeq(lang, ",") {
valid = true l = strings.TrimSpace(l)
break if l != "" {
languages = append(languages, l)
} }
} }
if len(languages) == 0 {
return []string{consts.DefaultInfoLanguage}
}
return languages
}
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
if !valid { if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error()) log.Error(err.Error())
@@ -557,6 +586,7 @@ func setViperDefaults() {
viper.SetDefault("autotranscodedownload", false) viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false) viper.SetDefault("searchfullstring", false)
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false) viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
@@ -609,19 +639,23 @@ func setViperDefaults() {
viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic") viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer") viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en") viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "") viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", "en") viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true) 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("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "") viper.SetDefault("backup.path", "")
@@ -634,7 +668,7 @@ func setViperDefaults() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("plugins.folder", "") viper.SetDefault("plugins.folder", "")
viper.SetDefault("plugins.enabled", false) viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false) viper.SetDefault("plugins.autoreload", false)

View File

@@ -26,6 +26,32 @@ var _ = Describe("Configuration", func() {
conf.ResetConf() conf.ResetConf()
}) })
Describe("ParseLanguages", func() {
It("parses single language", func() {
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
})
It("parses multiple comma-separated languages", func() {
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
})
It("trims whitespace from languages", func() {
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
})
It("returns default 'en' when empty", func() {
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
})
It("returns default 'en' when only whitespace", func() {
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
})
It("handles multiple languages with various spacing", func() {
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
})
})
DescribeTable("should load configuration from", DescribeTable("should load configuration from",
func(format string) { func(format string) {
filename := filepath.Join("testdata", "cfg."+format) filename := filepath.Join("testdata", "cfg."+format)

View File

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

View File

@@ -56,6 +56,8 @@ const (
ServerReadHeaderTimeout = 3 * time.Second ServerReadHeaderTimeout = 3 * time.Second
DefaultInfoLanguage = "en"
ArtistInfoTimeToLive = 24 * time.Hour ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute UpdateLastAccessFrequency = time.Minute
@@ -72,6 +74,10 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second 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" DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b') Zwsp = string('\u200b')
@@ -145,7 +151,13 @@ var (
Name: "aac audio", Name: "aac audio",
TargetFormat: "aac", TargetFormat: "aac",
DefaultBitRate: 256, DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
}, },
} }
) )

View File

@@ -22,6 +22,8 @@ type PluginLoader interface {
LoadMediaAgent(name string) (Interface, bool) LoadMediaAgent(name string) (Interface, bool)
} }
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
// until one returns valid data.
type Agents struct { type Agents struct {
ds model.DataStore ds model.DataStore
pluginLoader PluginLoader pluginLoader PluginLoader
@@ -129,26 +131,14 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
case consts.VariousArtistsID: case consts.VariousArtistsID:
return "", nil return "", nil
} }
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistMBIDRetriever) retriever, ok := ag.(ArtistMBIDRetriever)
if !ok { if !ok {
continue return "", ErrNotFound
} }
mbid, err := retriever.GetArtistMBID(ctx, id, name) return retriever.GetArtistMBID(ctx, id, name)
if mbid != "" && err == nil { })
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, nil
}
}
return "", ErrNotFound
} }
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
@@ -158,26 +148,14 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
case consts.VariousArtistsID: case consts.VariousArtistsID:
return "", nil return "", nil
} }
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistURLRetriever) retriever, ok := ag.(ArtistURLRetriever)
if !ok { if !ok {
continue return "", ErrNotFound
} }
url, err := retriever.GetArtistURL(ctx, id, name, mbid) return retriever.GetArtistURL(ctx, id, name, mbid)
if url != "" && err == nil { })
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, nil
}
}
return "", ErrNotFound
} }
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
@@ -187,26 +165,14 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
case consts.VariousArtistsID: case consts.VariousArtistsID:
return "", nil return "", nil
} }
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistBiographyRetriever) retriever, ok := ag.(ArtistBiographyRetriever)
if !ok { if !ok {
continue return "", ErrNotFound
} }
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid) return retriever.GetArtistBiography(ctx, id, name, mbid)
if err == nil { })
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, nil
}
}
return "", ErrNotFound
} }
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled // GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
@@ -254,26 +220,14 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
case consts.VariousArtistsID: case consts.VariousArtistsID:
return nil, nil return nil, nil
} }
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistImageRetriever) retriever, ok := ag.(ArtistImageRetriever)
if !ok { if !ok {
continue return nil, ErrNotFound
} }
images, err := retriever.GetArtistImages(ctx, id, name, mbid) return retriever.GetArtistImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil { })
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
} }
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled // GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
@@ -288,80 +242,127 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier) overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now() return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistTopSongsRetriever) retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok { if !ok {
continue return nil, ErrNotFound
} }
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
if len(songs) > 0 && err == nil { })
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil
}
}
return nil, ErrNotFound
} }
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
if name == consts.UnknownAlbum { if name == consts.UnknownAlbum {
return nil, ErrNotFound return nil, ErrNotFound
} }
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumInfoRetriever) retriever, ok := ag.(AlbumInfoRetriever)
if !ok { if !ok {
continue return nil, ErrNotFound
} }
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid) return retriever.GetAlbumInfo(ctx, name, artist, mbid)
if err == nil { })
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return album, nil
}
}
return nil, ErrNotFound
} }
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) { func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
if name == consts.UnknownAlbum { if name == consts.UnknownAlbum {
return nil, ErrNotFound return nil, ErrNotFound
} }
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
retriever, ok := ag.(AlbumImageRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetAlbumImages(ctx, name, artist, mbid)
})
}
// GetSimilarSongsByTrack returns similar songs for a given track.
func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByTrackRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByAlbum returns similar songs for a given album.
func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByArtist returns similar songs for a given artist.
func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
switch id {
case consts.UnknownArtistID:
return nil, ErrNotFound
case consts.VariousArtistsID:
return nil, nil
}
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByArtistRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
})
}
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
var zero T
start := time.Now() start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() { for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent) ag := agents.getAgent(enabledAgent)
if ag == nil { if ag == nil {
continue continue
} }
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
break break
} }
retriever, ok := ag.(AlbumImageRetriever) result, err := fn(ag)
if !ok { if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue continue
} }
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
if err != nil { if result != zero {
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err) log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
return result, nil
} }
if len(images) > 0 && err == nil { }
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist, return zero, ErrNotFound
"mbid", mbid, "elapsed", time.Since(start)) }
return images, nil
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
results, err := fn(ag)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if len(results) > 0 {
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
return results, nil
} }
} }
return nil, ErrNotFound return nil, ErrNotFound
@@ -376,3 +377,6 @@ var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil) var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil) var _ AlbumImageRetriever = (*Agents)(nil)
var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
var _ SimilarSongsByArtistRetriever = (*Agents)(nil)

View File

@@ -295,11 +295,77 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetSimilarSongsByTrack", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Similar Song",
MBID: "mbid555",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByAlbum", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByArtist", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
}) })
}) })
type mockAgent struct { type mockAgent struct {
Args []interface{} Args []any
Err error Err error
} }
@@ -308,7 +374,7 @@ func (a *mockAgent) AgentName() string {
} }
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) { 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 { if a.Err != nil {
return "", a.Err return "", a.Err
} }
@@ -316,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) { 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 { if a.Err != nil {
return "", a.Err return "", a.Err
} }
@@ -324,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) { 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 { if a.Err != nil {
return "", a.Err return "", a.Err
} }
@@ -332,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) { 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 { if a.Err != nil {
return nil, a.Err return nil, a.Err
} }
@@ -343,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) { 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 { if a.Err != nil {
return nil, a.Err return nil, a.Err
} }
@@ -354,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) { 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 { if a.Err != nil {
return nil, a.Err return nil, a.Err
} }
@@ -365,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) { 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 { if a.Err != nil {
return nil, a.Err return nil, a.Err
} }
@@ -377,6 +443,39 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
}, nil }, nil
} }
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Similar Song",
MBID: "mbid555",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}, nil
}
type emptyAgent struct { type emptyAgent struct {
Interface Interface
} }
@@ -389,12 +488,12 @@ type testImageAgent struct {
Name string Name string
Images []ExternalImage Images []ExternalImage
Err error Err error
Args []interface{} Args []any
} }
func (t *testImageAgent) AgentName() string { return t.Name } func (t *testImageAgent) AgentName() string { return t.Name }
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { 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 return t.Images, t.Err
} }

View File

@@ -33,9 +33,15 @@ type ExternalImage struct {
} }
type Song struct { type Song struct {
ID string ID string
Name string Name string
MBID string MBID string
ISRC string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
} }
var ( var (
@@ -76,6 +82,41 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
} }
// SimilarSongsByTrackRetriever provides similar songs based on a specific track
type SimilarSongsByTrackRetriever interface {
// GetSimilarSongsByTrack returns songs similar to the given track.
// Parameters:
// - id: local mediafile ID
// - name: track title
// - artist: artist name
// - mbid: MusicBrainz recording ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByAlbumRetriever provides similar songs based on an album
type SimilarSongsByAlbumRetriever interface {
// GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
// Parameters:
// - id: local album ID
// - name: album name
// - artist: album artist name
// - mbid: MusicBrainz release ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByArtistRetriever provides similar songs based on an artist
type SimilarSongsByArtistRetriever interface {
// GetSimilarSongsByArtist returns songs similar to the artist's catalog.
// Parameters:
// - id: local artist ID
// - name: artist name
// - mbid: MusicBrainz artist ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
}
var Map map[string]Constructor var Map map[string]Constructor
func Register(name string, init Constructor) { func Register(name string, init Constructor) {

View File

@@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser var r io.ReadCloser
if format != "raw" && format != "" { if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0) r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate})
} else { } else {
r, err = os.Open(path) r, err = os.Open(path)
} }

View File

@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil) }}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo) ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer) out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil) }}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo) ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer) out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
} }
sh.On("Load", mock.Anything, "1").Return(share, nil) sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer) out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out) err := arch.ZipShare(context.Background(), "1", out)
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{} plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo) ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer) out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -217,8 +217,8 @@ type mockMediaStreamer struct {
core.MediaStreamer core.MediaStreamer
} }
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) { func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset) args := m.Called(ctx, mf, req)
if args.Error(1) != nil { if args.Error(1) != nil {
return nil, args.Error(1) return nil, args.Error(1)
} }

View File

@@ -302,6 +302,33 @@ var _ = Describe("Artwork", func() {
Entry("landscape jpg image", "jpg", true, 200), Entry("landscape jpg image", "jpg", true, 200),
) )
}) })
When("Requested size is larger than original", func() {
It("clamps size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 should return at original size
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
It("clamps square size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 with square should return 16x16 square
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
})
}) })
}) })

View File

@@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() {
It("processes items in batches", func() { It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := 0; i < 5; i++ { for i := range 5 {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) 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 { func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc var ff []sourceFunc
for _, pattern := range strings.Split(strings.ToLower(priority), ",") { for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
switch { switch {
case pattern == "embedded": 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 { func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
var ff []sourceFunc var ff []sourceFunc
for _, pattern := range strings.Split(strings.ToLower(priority), ",") { for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
switch { switch {
case pattern == "external": 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 { func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) { return func() (io.ReadCloser, string, error) {
current := artistFolder current := artistFolder
for i := 0; i < maxArtistFolderTraversalDepth; i++ { for range maxArtistFolderTraversalDepth {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil return reader, path, nil
} }

View File

@@ -87,6 +87,11 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error
bounds := original.Bounds() bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y) originalSize := max(bounds.Max.X, bounds.Max.Y)
// Clamp size to original dimensions - upscaling wastes resources and adds no information
if size > originalSize {
size = originalSize
}
if originalSize <= size && !square { if originalSize <= size && !square {
return nil, originalSize, nil return nil, originalSize, nil
} }

View File

@@ -16,12 +16,14 @@ import (
"time" "time"
"github.com/dhowden/tag" "github.com/dhowden/tag"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/resources"
"go.senan.xyz/taglib"
) )
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
@@ -84,6 +86,13 @@ var picTypeRegexes = []*regexp.Regexp{
} }
func fromTag(ctx context.Context, path string) sourceFunc { func fromTag(ctx context.Context, path string) sourceFunc {
if conf.Server.DevLegacyEmbedImage {
return fromTagLegacy(ctx, path)
}
return fromTagGoTaglib(ctx, path)
}
func fromTagLegacy(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) { return func() (io.ReadCloser, string, error) {
if path == "" { if path == "" {
return nil, "", nil return nil, "", nil
@@ -128,6 +137,44 @@ func fromTag(ctx context.Context, path string) sourceFunc {
} }
} }
func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
}
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
if err != nil {
return nil, "", err
}
defer f.Close()
images := f.Properties().Images
if len(images) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
imageIndex := findBestImageIndex(ctx, images, path)
data, err := f.Image(imageIndex)
if err != nil || len(data) == 0 {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
}
return io.NopCloser(bytes.NewReader(data)), path, nil
}
}
func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
for _, regex := range picTypeRegexes {
for i, img := range images {
if regex.MatchString(img.Type) {
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
return i
}
}
}
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
return 0
}
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) { return func() (io.ReadCloser, string, error) {
if path == "" { if path == "" {

View File

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

View File

@@ -45,7 +45,7 @@ var _ = Describe("Auth", func() {
}) })
It("returns the claims from a valid JWT token", func() { It("returns the claims from a valid JWT token", func() {
claims := map[string]interface{}{} claims := map[string]any{}
claims["iss"] = "issuer" claims["iss"] = "issuer"
claims["iat"] = time.Now().Unix() claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(1 * time.Minute).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() { It("returns ErrExpired if the `exp` field is in the past", func() {
claims := map[string]interface{}{} claims := map[string]any{}
claims["iss"] = "issuer" claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix() claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
_, tokenStr, err := auth.TokenAuth.Encode(claims) _, tokenStr, err := auth.TokenAuth.Encode(claims)
@@ -93,7 +93,7 @@ var _ = Describe("Auth", func() {
Describe("TouchToken", func() { Describe("TouchToken", func() {
It("updates the expiration time", func() { It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay) yesterday := time.Now().Add(-oneDay)
claims := map[string]interface{}{} claims := map[string]any{}
claims["iss"] = "issuer" claims["iss"] = "issuer"
claims["exp"] = yesterday.Unix() claims["exp"] = yesterday.Unix()
token, _, err := auth.TokenAuth.Encode(claims) 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. // GetAll implements model.ArtistRepository.
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { 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 { for i, v := range options {
argsSlice[i] = v argsSlice[i] = v
} }
@@ -92,9 +92,14 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
return args.Get(0).(*model.MediaFile), args.Error(1) return args.Get(0).(*model.MediaFile), args.Error(1)
} }
// GetAllByTags implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(options...)
}
// GetAll implements model.MediaFileRepository. // GetAll implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { 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 { for i, v := range options {
argsSlice[i] = v argsSlice[i] = v
} }
@@ -147,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
// GetAll implements model.AlbumRepository. // GetAll implements model.AlbumRepository.
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) { 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 { for i, v := range options {
argsSlice[i] = v argsSlice[i] = v
} }
@@ -282,3 +287,27 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri
} }
return nil, args.Error(1) return nil, args.Error(1)
} }
func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, artist, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}
func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, artist, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}
func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}

View File

@@ -32,7 +32,7 @@ const (
type Provider interface { type Provider interface {
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error) ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error)
@@ -80,6 +80,9 @@ type Agents interface {
agents.ArtistSimilarRetriever agents.ArtistSimilarRetriever
agents.ArtistTopSongsRetriever agents.ArtistTopSongsRetriever
agents.ArtistURLRetriever agents.ArtistURLRetriever
agents.SimilarSongsByTrackRetriever
agents.SimilarSongsByAlbumRetriever
agents.SimilarSongsByArtistRetriever
} }
func NewProvider(ds model.DataStore, agents Agents) Provider { func NewProvider(ds model.DataStore, agents Agents) Provider {
@@ -90,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider {
} }
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { 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) entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil { if err != nil {
return auxAlbum{}, err return auxAlbum{}, err
@@ -184,7 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
} }
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) { 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) entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil { if err != nil {
return auxArtist{}, err return auxArtist{}, err
@@ -256,7 +259,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
_ = g.Wait() _ = g.Wait()
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@@ -275,22 +278,54 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, nil return artist, nil
} }
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) { func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
}
var songs []agents.Song
// Try entity-specific similarity first
switch v := entity.(type) {
case *model.MediaFile:
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
case *model.Album:
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
case *model.Artist:
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
default:
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
return nil, model.ErrNotFound
}
if err == nil && len(songs) > 0 {
return e.matchSongsToLibrary(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
return e.similarSongsFallback(ctx, id, count)
}
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
// a weighted random selection of songs to return as similar songs.
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id) artist, err := e.getArtist(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
e.callGetSimilar(ctx, e.ag, &artist, 15, false) e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err() return nil, ctx.Err()
} }
weightedSongs := random.NewWeightedChooser[model.MediaFile]() weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err() return ctx.Err()
} }
@@ -422,21 +457,20 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
} }
idMatches, err := e.loadTracksByID(ctx, songs) // Enrich songs with artist info if not already present (for top songs, we know the artist)
if err != nil { for i := range songs {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err) if songs[i].Artist == "" {
} songs[i].Artist = artistName
mbidMatches, err := e.loadTracksByMBID(ctx, songs) }
if err != nil { if songs[i].ArtistMBID == "" {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) songs[i].ArtistMBID = artist.MbzArtistID
} }
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
} }
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches)) mfs, err := e.matchSongsToLibrary(ctx, songs, count)
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count) if err != nil {
return nil, err
}
if len(mfs) == 0 { if len(mfs) == 0 {
log.Debug(ctx, "No matching top songs found", "name", artistName) log.Debug(ctx, "No matching top songs found", "name", artistName)
@@ -447,137 +481,6 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return mfs, nil return mfs, nil
} }
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var mbids []string
for _, s := range songs {
if s.MBID != "" {
mbids = append(mbids, s.MBID)
}
}
matches := map[string]model.MediaFile{}
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := matches[id]; !ok {
matches[id] = mf
}
}
}
return matches, nil
}
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var ids []string
for _, s := range songs {
if s.ID != "" {
ids = append(ids, s.ID)
}
}
matches := map[string]model.MediaFile{}
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if _, ok := matches[mf.ID]; !ok {
matches[mf.ID] = mf
}
}
return matches, nil
}
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
titleMap := map[string]string{}
for _, s := range songs {
// Skip if already matched by ID or MBID
if s.ID != "" && idMatches[s.ID].ID != "" {
continue
}
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
continue
}
sanitized := str.SanitizeFieldForSorting(s.Name)
titleMap[sanitized] = s.Name
}
matches := map[string]model.MediaFile{}
if len(titleMap) == 0 {
return matches, nil
}
titleFilters := squirrel.Or{}
for sanitized := range titleMap {
titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Or{
squirrel.Eq{"artist_id": artist.ID},
squirrel.Eq{"album_artist_id": artist.ID},
},
titleFilters,
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc ",
})
if err != nil {
return matches, err
}
for _, mf := range res {
sanitized := str.SanitizeFieldForSorting(mf.Title)
if _, ok := matches[sanitized]; !ok {
matches[sanitized] = mf
}
}
return matches, nil
}
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
var mfs model.MediaFiles
for _, t := range songs {
if len(mfs) == count {
break
}
// Try ID match first
if t.ID != "" {
if mf, ok := byID[t.ID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Try MBID match second
if t.MBID != "" {
if mf, ok := byMBID[t.MBID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Fall back to title match
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
mfs = append(mfs, mf)
}
}
return mfs
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil { if err != nil {
@@ -614,7 +517,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
} }
} }
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) { limit int, includeNotPresent bool) {
artistName := artist.Name() artistName := artist.Name()
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)

View File

@@ -1,205 +0,0 @@
package external_test
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - ArtistRadio", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
var agentsCombined Agents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
}
mockAgent = &mockSimilarArtistAgent{}
mockTopAgent = mockAgent
mockSimilarAgent = mockAgent
agentsCombined = &mockAgents{
topSongsAgent: mockTopAgent,
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined)
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
// MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
// Name lookup returns the similar artist
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})

504
core/external/provider_matching.go vendored Normal file
View File

@@ -0,0 +1,504 @@
package external
import (
"context"
"fmt"
"math"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
"github.com/xrash/smetrics"
)
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
//
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
// local music library using four matching strategies in priority order:
//
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
// matching mbz_recording_id
// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag
// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
// with metadata specificity scoring
//
// # Matching Priority
//
// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist.
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
//
// # Fuzzy Matching Details
//
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
//
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
// 3. Specificity level (0-5, based on metadata precision):
// - Level 5: Title + Artist MBID + Album MBID (most specific)
// - Level 4: Title + Artist MBID + Album name (fuzzy)
// - Level 3: Title + Artist name + Album name (fuzzy)
// - Level 2: Title + Artist MBID
// - Level 1: Title + Artist name
// - Level 0: Title only
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
//
// # Examples
//
// Example 1 - MBID Priority:
//
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
// Library has: [
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
// ]
// Result: t1 (MBID match takes priority over title+artist)
//
// Example 2 - ISRC Priority:
//
// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}
// Library has: [
// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}},
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
// ]
// Result: t1 (ISRC match takes priority over title+artist)
//
// Example 3 - Specificity Ranking:
//
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
// Library has: [
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
// ]
// Result: t2 (Level 3 beats Level 1 due to album match)
//
// Example 4 - Fuzzy Title Matching:
//
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
// With threshold=85%: Match succeeds (similarity ~0.87)
// With threshold=100%: No match (not exact)
//
// # Parameters
//
// - ctx: Context for database operations
// - songs: Slice of agent.Song results from external providers
// - count: Maximum number of matches to return
//
// # Returns
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := e.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
}
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
_, found := lookupByIdentifiers(s, priorMatches...)
return found
}
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
keys := []string{s.ID, s.MBID, s.ISRC}
for _, m := range maps {
for _, key := range keys {
if key != "" {
if mf, ok := m[key]; ok && mf.ID != "" {
return mf, true
}
}
}
}
return model.MediaFile{}, false
}
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
// It extracts all non-empty ID fields from the input songs and performs a single
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
// Only non-missing files are returned.
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var ids []string
for _, s := range songs {
if s.ID != "" {
ids = append(ids, s.ID)
}
}
matches := map[string]model.MediaFile{}
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if _, ok := matches[mf.ID]; !ok {
matches[mf.ID] = mf
}
}
return matches, nil
}
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
// It extracts all non-empty MBID fields from the input songs and performs a single
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
// O(1) lookup. Only non-missing files are returned.
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
var mbids []string
for _, s := range songs {
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
mbids = append(mbids, s.MBID)
}
}
matches := map[string]model.MediaFile{}
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := matches[id]; !ok {
matches[id] = mf
}
}
}
return matches, nil
}
// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard
// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and
// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for
// O(1) lookup. Only non-missing files are returned.
func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
var isrcs []string
for _, s := range songs {
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
isrcs = append(isrcs, s.ISRC)
}
}
matches := map[string]model.MediaFile{}
if len(isrcs) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
Filters: squirrel.Eq{"missing": false},
})
if err != nil {
return matches, err
}
for _, mf := range res {
for _, isrc := range mf.Tags.Values(model.TagISRC) {
if _, ok := matches[isrc]; !ok {
matches[isrc] = mf
}
}
}
return matches, nil
}
// songQuery represents a normalized query for matching a song to library tracks.
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
type songQuery struct {
title string // Sanitized song title
artist string // Sanitized artist name (without articles like "The")
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
album string // Sanitized album name (optional, for specificity scoring)
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
}
// matchScore combines title/album similarity with metadata specificity for ranking matches
type matchScore struct {
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown)
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
specificityLevel int // 0-5 (higher = more specific metadata match)
}
// betterThan returns true if this score beats another.
// Comparison order: title similarity > duration proximity > specificity level > album similarity
func (s matchScore) betterThan(other matchScore) bool {
if s.titleSimilarity != other.titleSimilarity {
return s.titleSimilarity > other.titleSimilarity
}
if s.durationProximity != other.durationProximity {
return s.durationProximity > other.durationProximity
}
if s.specificityLevel != other.specificityLevel {
return s.specificityLevel > other.specificityLevel
}
return s.albumSimilarity > other.albumSimilarity
}
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
// Higher values indicate more specific matches (MBIDs > names > title only).
// Uses fuzzy matching for album names with the same threshold as title matching.
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
title := str.SanitizeFieldForSorting(mf.Title)
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
album := str.SanitizeFieldForSorting(mf.Album)
// Level 5: Title + Artist MBID + Album MBID (most specific)
if q.artistMBID != "" && q.albumMBID != "" &&
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
return 5
}
// Level 4: Title + Artist MBID + Album name (fuzzy)
if q.artistMBID != "" && q.album != "" &&
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
return 4
}
// Level 3: Title + Artist name + Album name (fuzzy)
if q.artist != "" && q.album != "" &&
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
return 3
}
// Level 2: Title + Artist MBID
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
return 2
}
// Level 1: Title + Artist name
if q.artist != "" && artist == q.artist {
return 1
}
// Level 0: Title only match (but for fuzzy, title matched via similarity)
// Check if at least the title matches exactly
if title == q.title {
return 0
}
return -1 // No exact title match, but could still be a fuzzy match
}
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
queries := e.buildTitleQueries(songs, priorMatches...)
if len(queries) == 0 {
return map[string]model.MediaFile{}, nil
}
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
// Group queries by artist for efficient DB access
byArtist := map[string][]songQuery{}
for _, q := range queries {
if q.artist != "" {
byArtist[q.artist] = append(byArtist[q.artist], q)
}
}
matches := map[string]model.MediaFile{}
for artist, artistQueries := range byArtist {
// Single DB query per artist - get all their tracks
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"order_artist_name": artist},
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
if err != nil {
continue
}
// Find best match for each query using unified scoring
for _, q := range artistQueries {
if mf, found := e.findBestMatch(q, tracks, threshold); found {
key := q.title + "|" + q.artist
if _, exists := matches[key]; !exists {
matches[key] = mf
}
}
}
}
return matches, nil
}
// durationProximity returns a score from 0.0 to 1.0 indicating how close
// the track's duration is to the target. A perfect match returns 1.0, and the
// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0
// if durationMs is 0 (unknown), so duration does not influence scoring.
func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
if durationMs <= 0 {
return 1.0 // Unknown duration — don't penalise
}
durationSec := float64(durationMs) / 1000.0
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
return 1.0 / (1.0 + diff)
}
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
// A track must meet the threshold for title similarity, then the best match is chosen by:
// 1. Highest title similarity
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
// 3. Highest specificity level
// 4. Highest album similarity (as final tiebreaker)
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
var bestMatch model.MediaFile
bestScore := matchScore{titleSimilarity: -1}
found := false
for _, mf := range tracks {
trackTitle := str.SanitizeFieldForSorting(mf.Title)
titleSim := similarityRatio(q.title, trackTitle)
if titleSim < threshold {
continue
}
// Compute album similarity for tiebreaking (0.0 if no album in query)
var albumSim float64
if q.album != "" {
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
albumSim = similarityRatio(q.album, trackAlbum)
}
score := matchScore{
titleSimilarity: titleSim,
durationProximity: durationProximity(q.durationMs, mf.Duration),
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, mf, threshold),
}
if score.betterThan(bestScore) {
bestScore = score
bestMatch = mf
found = true
}
}
return bestMatch, found
}
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
var queries []songQuery
for _, s := range songs {
if songMatchedIn(s, priorMatches...) {
continue
}
queries = append(queries, songQuery{
title: str.SanitizeFieldForSorting(s.Name),
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
artistMBID: s.ArtistMBID,
album: str.SanitizeFieldForSorting(s.Album),
albumMBID: s.AlbumMBID,
durationMs: s.Duration,
})
}
return queries
}
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
// library tracks. It iterates through the input songs in order and selects the first available match
// using priority order: ID > MBID > ISRC > title+artist.
//
// The function also handles deduplication: when multiple different input songs would match the same
// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
// only the first match is kept. However, if the same input song appears multiple times (intentional
// repetition), duplicates are preserved in the output.
//
// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
mfs := make(model.MediaFiles, 0, len(songs))
// Track MediaFile.ID -> input song that added it, for deduplication
addedBy := make(map[string]agents.Song, len(songs))
for _, t := range songs {
if len(mfs) == count {
break
}
mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
if !found {
continue
}
// Check for duplicate library track
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
// Only add duplicate if input songs are identical
if t != prevSong {
continue // Different input songs → skip mismatch-induced duplicate
}
} else {
addedBy[mf.ID] = t
}
mfs = append(mfs, mf)
}
return mfs
}
// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
// Try identifier-based matches first (ID, MBID, ISRC)
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
return mf, true
}
// Fall back to title+artist fuzzy match
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
if mf, ok := byTitleArtist[key]; ok {
return mf, true
}
return model.MediaFile{}, false
}
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
// Returns a value between 0.0 (completely different) and 1.0 (identical).
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
func similarityRatio(a, b string) float64 {
if a == b {
return 1.0
}
if len(a) == 0 || len(b) == 0 {
return 0.0
}
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
return smetrics.JaroWinkler(a, b, 0.7, 4)
}

View File

@@ -0,0 +1,57 @@
package external
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("similarityRatio", func() {
It("returns 1.0 for identical strings", func() {
Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
})
It("returns 0.0 for empty strings", func() {
Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
})
It("returns high similarity for remastered suffix", func() {
// Jaro-Winkler gives ~0.92 for this case
ratio := similarityRatio("paranoid android", "paranoid android remastered")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns high similarity for suffix additions like (Live)", func() {
// Jaro-Winkler gives ~0.96 for this case
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
Expect(ratio).To(BeNumerically(">=", 0.90))
})
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
// Jaro-Winkler gives ~0.90 because of common prefix
ratio := similarityRatio("yesterday", "yesterday once more")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns low similarity for same suffix", func() {
// Jaro-Winkler gives ~0.70 for this case
ratio := similarityRatio("postman (live)", "taxman (live)")
Expect(ratio).To(BeNumerically("<", 0.85))
})
It("handles unicode characters", func() {
ratio := similarityRatio("dont stop believin", "don't stop believin'")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns low similarity for completely different strings", func() {
ratio := similarityRatio("abc", "xyz")
Expect(ratio).To(BeNumerically("<", 0.5))
})
It("is symmetric", func() {
ratio1 := similarityRatio("hello world", "hello")
ratio2 := similarityRatio("hello", "hello world")
Expect(ratio1).To(Equal(ratio2))
})
})

762
core/external/provider_matching_test.go vendored Normal file
View File

@@ -0,0 +1,762 @@
package external_test
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - Song Matching", func() {
var ds model.DataStore
var provider Provider
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
agentsCombined = &mockAgents{}
provider = NewProvider(ds, agentsCombined)
})
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByTitleAndArtist - queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Describe("matchSongsToLibrary priority matching", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(idMatches, nil).Once()
// loadTracksByMBID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(mbidMatches, nil).Once()
// loadTracksByTitleAndArtist - now queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Context("when agent returns artist and album metadata", func() {
It("matches by title + artist MBID + album MBID (highest priority)", func() {
// Song in library with all MBIDs
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
}
// Another song with same title but different MBIDs (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist name + album name when MBIDs unavailable", func() {
// Song in library without MBIDs but with matching artist/album names
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
}
// Another song with same title but different artist (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist only when album info unavailable", func() {
// Song in library with matching artist
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
}
// Another song with same title but different artist
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("does not match songs without artist info", func() {
// Songs without artist info cannot be matched since we query by artist
returnedSongs := []agents.Song{
{Name: "Similar Song"}, // No artist/album info at all
}
// No artist to query, so no GetAll calls for title matching
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("when matching multiple songs with the same title but different artists", func() {
It("returns distinct matches for each artist's version (covers scenario)", func() {
// Multiple covers of the same song by different artists
cover1 := model.MediaFile{
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
cover2 := model.MediaFile{
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
}
cover3 := model.MediaFile{
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
}
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// All three covers should be returned, not just the first one
Expect(songs).To(HaveLen(3))
// Verify all three different versions are included
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
})
})
Context("when matching multiple songs with different precision levels", func() {
It("prefers more precise matches for each song", func() {
// Library has multiple versions of same song
preciseMatch := model.MediaFile{
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
}
lessAccurateMatch := model.MediaFile{
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
MbzArtistID: "mbid-1",
}
artistTwoMatch := model.MediaFile{
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
}
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
{Name: "Song B", Artist: "Artist Two"}, // Different artist
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
// First song should be the precise match (has all MBIDs)
Expect(songs[0].ID).To(Equal("precise"))
// Second song matches by title + artist
Expect(songs[1].ID).To(Equal("artist-two"))
})
})
})
Describe("Fuzzy matching fallback", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has the remastered version (fuzzy match will find it)
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("remastered"))
})
It("matches songs with live suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
artistTracks := model.MediaFiles{
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("live"))
})
It("does not match completely different songs", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles"},
}
// Artist catalog has completely different songs
artistTracks := model.MediaFiles{
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with threshold set to 100 (exact match only)", func() {
It("only matches exact titles", func() {
conf.Server.SimilarSongsMatchThreshold = 100
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has only remastered version - no exact match
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with lower threshold (75%)", func() {
It("matches more aggressively", func() {
conf.Server.SimilarSongsMatchThreshold = 75
returnedSongs := []agents.Song{
{Name: "Song", Artist: "Artist"},
}
artistTracks := model.MediaFiles{
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("extended"))
})
})
Context("with fuzzy album matching", func() {
It("matches album with (Remaster) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "A Night at the Opera" but library has remastered version
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
// Library has same album with remaster suffix
correctMatch := model.MediaFile{
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches album with (Deluxe Edition) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("prefers exact album match over fuzzy album match", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
exactMatch := model.MediaFile{
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
fuzzyMatch := model.MediaFile{
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
Expect(songs[0].ID).To(Equal("exact"))
})
})
})
Describe("Duration matching", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("when agent provides duration", func() {
It("prefers tracks with matching duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has two versions: one matching duration, one not
correctMatch := model.MediaFile{
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
}
wrongDuration := model.MediaFile{
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches tracks with close duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has track with 182.5 seconds (close to target)
closeDuration := model.MediaFile{
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("close-duration"))
})
It("prefers closer duration over farther duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has one close, one far
closeDuration := model.MediaFile{
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
}
farDuration := model.MediaFile{
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("close"))
})
It("still matches when no tracks have matching duration", func() {
// Agent returns song with duration 180000ms
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library only has tracks with very different duration
differentDuration := model.MediaFile{
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Duration mismatch doesn't exclude the track; it's just scored lower
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("different"))
})
It("prefers title match over duration match when titles differ", func() {
// Agent returns "Similar Song" with duration 180000ms
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has:
// - differentTitle: matches duration but has different title (won't pass title threshold)
// - correctTitle: doesn't match duration but has correct title (wins on title similarity)
differentTitle := model.MediaFile{
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
}
correctTitle := model.MediaFile{
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Title similarity is the top priority, so the correct title wins despite duration mismatch
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-title"))
})
})
Context("when agent does not provide duration", func() {
It("matches without duration filtering (duration=0)", func() {
// Agent returns song without duration
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
}
// Library tracks with various durations should all be candidates
anyTrack := model.MediaFile{
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("any"))
})
})
Context("edge cases", func() {
It("handles very short songs with close duration", func() {
// 30-second song with 1-second difference
returnedSongs := []agents.Song{
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
}
shortTrack := model.MediaFile{
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("short"))
})
})
})
Describe("Deduplication of mismatched songs", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
It("removes duplicates when different input songs match the same library track", func() {
// Agent returns two different versions that will both fuzzy-match to the same library track
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
}
// Library only has one version
libraryTrack := model.MediaFile{
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should only return one track, not two duplicates
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("br-live"))
})
It("preserves duplicates when identical input songs match the same library track", func() {
// Agent returns the exact same song twice (intentional repetition)
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
// Library has matching track
libraryTrack := model.MediaFile{
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should return two tracks since input songs were identical
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("br"))
Expect(songs[1].ID).To(Equal("br"))
})
It("handles mixed scenario with both identical and different input songs", func() {
// Agent returns: Song A, Song B (different from A), Song A again (same as first)
// All three match to the same library track
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
}
// Library only has one version
libraryTrack := model.MediaFile{
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should return 2 tracks:
// 1. First "Yesterday" (original)
// 2. Third "Yesterday" (same as first, so kept)
// Skip: Second "Yesterday (Remastered)" (different input, same library track)
// Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("yesterday"))
Expect(songs[1].ID).To(Equal("yesterday"))
})
It("does not deduplicate songs that match different library tracks", func() {
// Agent returns different songs that match different library tracks
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
{Name: "Song C", Artist: "Artist"},
}
// Library has all three songs
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// All three should be returned since they match different library tracks
Expect(songs).To(HaveLen(3))
Expect(songs[0].ID).To(Equal("track-a"))
Expect(songs[1].ID).To(Equal("track-b"))
Expect(songs[2].ID).To(Equal("track-c"))
})
It("respects count limit after deduplication", func() {
// Agent returns 4 songs: 2 unique + 2 that would create duplicates
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
{Name: "Song B", Artist: "Artist"},
{Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
}
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
// Request only 2 songs
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
Expect(err).ToNot(HaveOccurred())
// Should return exactly 2: Song A and Song B (skipping duplicates)
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("track-a"))
Expect(songs[1].ID).To(Equal("track-b"))
})
})
})

View File

@@ -0,0 +1,443 @@
package external_test
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - SimilarSongs", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
mockAgent = &mockSimilarArtistAgent{}
mockTopAgent = mockAgent
mockSimilarAgent = mockAgent
agentsCombined = &mockAgents{
topSongsAgent: mockTopAgent,
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined)
})
Describe("dispatch by entity type", func() {
Context("when ID is a MediaFile (track)", func() {
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
Return([]agents.Song{
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(model.MediaFiles{}, nil).Maybe()
// Mock loadTracksByTitleAndArtist - queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
Return([]agents.Song{}, nil).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
// and recursively calls getArtist(v.ArtistID)
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Album", func() {
It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
Return([]agents.Song{
{Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
// and recursively calls getArtist(v.AlbumArtistID)
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Artist", func() {
It("calls GetSimilarSongsByArtist and returns matched songs", func() {
artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
Return([]agents.Song{
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
})
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
// MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
// Name lookup returns the similar artist
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})

View File

@@ -7,6 +7,8 @@ import (
_ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify" _ "github.com/navidrome/navidrome/adapters/spotify"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external" . "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -26,6 +28,10 @@ var _ = Describe("Provider - TopSongs", func() {
) )
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo() // Use helper mock artistRepo = newMockArtistRepo() // Use helper mock

View File

@@ -12,11 +12,24 @@ import (
"sync" "sync"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
) )
// TranscodeOptions contains all parameters for a transcoding operation.
type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
}
type FFmpeg interface { type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error) Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error) CmdPath() (string, error)
@@ -35,15 +48,19 @@ const (
type ffmpeg struct{} type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) { func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil { if _, err := ffmpegCmd(); err != nil {
return nil, err return nil, err
} }
// First make sure the file exists if err := fileExists(opts.FilePath); err != nil {
if err := fileExists(path); err != nil {
return nil, err return nil, err
} }
args := createFFmpegCommand(command, path, maxBitRate, offset) var args []string
if isDefaultCommand(opts.Format, opts.Command) {
args = buildDynamicArgs(opts)
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args) return e.start(ctx, args)
} }
@@ -51,7 +68,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil { if _, err := ffmpegCmd(); err != nil {
return nil, err return nil, err
} }
// First make sure the file exists
if err := fileExists(path); err != nil { if err := fileExists(path); err != nil {
return nil, err return nil, err
} }
@@ -156,6 +172,139 @@ func (j *ffCmd) wait() {
_ = j.out.Close() _ = j.out.Close()
} }
// formatCodecMap maps target format to ffmpeg codec flag.
var formatCodecMap = map[string]string{
"mp3": "libmp3lame",
"opus": "libopus",
"aac": "aac",
"flac": "flac",
}
// formatOutputMap maps target format to ffmpeg output format flag (-f).
var formatOutputMap = map[string]string{
"mp3": "mp3",
"opus": "opus",
"aac": "ipod",
"flac": "flac",
}
// defaultCommands is used to detect whether a user has customized their transcoding command.
var defaultCommands = func() map[string]string {
m := make(map[string]string, len(consts.DefaultTranscodings))
for _, t := range consts.DefaultTranscodings {
m[t.TargetFormat] = t.Command
}
return m
}()
// isDefaultCommand returns true if the command matches the known default for this format.
func isDefaultCommand(format, command string) bool {
return defaultCommands[format] == command
}
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
args := []string{cmdPath, "-i", opts.FilePath}
if opts.Offset > 0 {
args = append(args, "-ss", strconv.Itoa(opts.Offset))
}
args = append(args, "-map", "0:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
args = append(args, "-c:a", codec)
}
if opts.BitRate > 0 {
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
}
if opts.SampleRate > 0 {
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = append(args, "-ac", strconv.Itoa(opts.Channels))
}
// Only pass -sample_fmt for lossless output formats where bit depth matters.
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
// and passing interleaved formats like "s16" causes silent failures.
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
args = append(args, "-v", "0")
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
args = append(args, "-f", outputFmt)
}
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
if opts.Format == "aac" {
args = append(args, "-movflags", "frag_keyframe+empty_moov")
}
args = append(args, "-")
return args
}
// buildTemplateArgs handles user-customized command templates, with dynamic injection
// of sample rate and channels when the template doesn't already include them.
func buildTemplateArgs(opts TranscodeOptions) []string {
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
// Dynamically inject -ar, -ac, and -sample_fmt for custom templates that don't include them
if opts.SampleRate > 0 {
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
}
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
return args
}
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
func injectBeforeOutput(args []string, flag, value string) []string {
if len(args) > 0 && args[len(args)-1] == "-" {
result := make([]string, 0, len(args)+2)
result = append(result, args[:len(args)-1]...)
result = append(result, flag, value, "-")
return result
}
return append(args, flag, value)
}
// isLosslessOutputFormat returns true if the format is a lossless audio format
// where preserving bit depth via -sample_fmt is meaningful.
// Note: this covers only formats ffmpeg can produce as output. For the full set of
// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat.
func isLosslessOutputFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff":
return true
}
return false
}
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
// (ffmpeg packs 24-bit samples into 32-bit containers).
func bitDepthToSampleFmt(bitDepth int) string {
switch bitDepth {
case 16:
return "s16"
case 32:
return "s32"
default:
// 24-bit and other depths: use s32 (the next valid container size)
return "s32"
}
}
// Path will always be an absolute path // Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string var args []string

View File

@@ -2,19 +2,27 @@ package ffmpeg
import ( import (
"context" "context"
"os"
"path/filepath"
"runtime" "runtime"
sync "sync" sync "sync"
"testing" "testing"
"time" "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
func TestFFmpeg(t *testing.T) { func TestFFmpeg(t *testing.T) {
tests.Init(t, false) // Inline test init to avoid import cycle with tests package
//nolint:dogsled
_, file, _, _ := runtime.Caller(0)
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
_ = os.Chdir(appPath)
conf.LoadFromFile(confPath)
log.SetLevel(log.LevelFatal) log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite") RunSpecs(t, "FFmpeg Suite")
@@ -70,6 +78,286 @@ var _ = Describe("ffmpeg", func() {
}) })
}) })
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
})
It("returns false for unknown format", func() {
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
})
})
Describe("buildDynamicArgs", func() {
It("builds mp3 args with bitrate, samplerate, and channels", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
SampleRate: 48000,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "256k",
"-ar", "48000",
"-ac", "2",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds flac args without bitrate", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-ar", "48000",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("builds opus args with bitrate only", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libopus",
"-b:a", "128k",
"-v", "0",
"-f", "opus",
"-",
}))
})
It("includes offset when specified", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.mp3",
BitRate: 192,
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"-ss", "30",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds aac args with fragmented MP4 container", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
"-f", "ipod",
"-movflags", "frag_keyframe+empty_moov",
"-",
}))
})
It("builds flac args with bit depth", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-sample_fmt", "s32",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("omits -sample_fmt when bit depth is 0", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.flac",
BitDepth: 0,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 1,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for mp3 even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for aac even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for opus even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
})
Describe("bitDepthToSampleFmt", func() {
It("converts 16-bit", func() {
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
})
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
})
It("converts 32-bit", func() {
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
})
})
Describe("buildTemplateArgs", func() {
It("injects -ar and -ac into custom template", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 44100,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "44100", "-ac", "2",
"-",
}))
})
It("injects only -ar when channels is 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "48000",
"-",
}))
})
It("does not inject anything when sample rate and channels are 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
It("injects -sample_fmt for lossless output format with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-v", "0", "-c:a", "flac", "-f", "flac",
"-sample_fmt", "s32",
"-",
}))
})
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 192,
BitDepth: 16,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
})
Describe("injectBeforeOutput", func() {
It("inserts flag before trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
})
It("appends when no trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
})
})
Describe("FFmpeg", func() { Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() { Context("when FFmpeg is available", func() {
var ff FFmpeg var ff FFmpeg
@@ -93,7 +381,12 @@ var _ = Describe("ffmpeg", func() {
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -" command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function // The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0) stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
defer stream.Close() defer stream.Close()
@@ -115,7 +408,12 @@ var _ = Describe("ffmpeg", func() {
cancel() // Cancel immediately cancel() // Cancel immediately
// This should fail immediately // This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0) _, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).To(MatchError(context.Canceled)) Expect(err).To(MatchError(context.Canceled))
}) })
}) })
@@ -142,7 +440,10 @@ var _ = Describe("ffmpeg", func() {
defer cancel() defer cancel()
// Start a process that will run for a while // Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0) stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
defer stream.Close() defer stream.Close()

View File

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

View File

@@ -9,7 +9,7 @@ import (
"sync" "sync"
"github.com/deluan/rest" "github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage

View File

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

View File

@@ -18,9 +18,20 @@ import (
"github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/cache"
) )
// StreamRequest contains all parameters for creating a media stream.
type StreamRequest struct {
ID string
Format string
BitRate int // kbps
SampleRate int
BitDepth int
Channels int
Offset int // seconds
}
type MediaStreamer interface { type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error) NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
} }
type TranscodingCache cache.FileCache type TranscodingCache cache.FileCache
@@ -36,44 +47,48 @@ type mediaStreamer struct {
} }
type streamJob struct { type streamJob struct {
ms *mediaStreamer ms *mediaStreamer
mf *model.MediaFile mf *model.MediaFile
filePath string filePath string
format string format string
bitRate int bitRate int
offset int sampleRate int
bitDepth int
channels int
offset int
} }
func (j *streamJob) Key() string { func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset) return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
} }
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id) mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset) return ms.DoStream(ctx, mf, req)
} }
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) {
var format string var format string
var bitRate int var bitRate int
var cached bool var cached bool
defer func() { defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached, log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}() }()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath() filePath := mf.AbsolutePath()
if format == "raw" { if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format) "selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath) f, err := os.Open(filePath)
@@ -87,12 +102,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
} }
job := &streamJob{ job := &streamJob{
ms: ms, ms: ms,
mf: mf, mf: mf,
filePath: filePath, filePath: filePath,
format: format, format: format,
bitRate: bitRate, bitRate: bitRate,
offset: reqOffset, sampleRate: req.SampleRate,
bitDepth: req.BitDepth,
channels: req.Channels,
offset: req.Offset,
} }
r, err := ms.cache.Get(ctx, job) r, err := ms.cache.Get(ctx, job)
if err != nil { if err != nil {
@@ -105,7 +123,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath, log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@@ -131,12 +149,13 @@ func (s *Stream) EstimatedContentLength() int {
} }
// TODO This function deserves some love (refactoring) // TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) {
format = "raw" format = "raw"
if reqFormat == "raw" { if reqFormat == "raw" {
return format, 0 return format, 0
} }
if reqFormat == mf.Suffix && reqBitRate == 0 { needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate
if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample {
bitRate = mf.BitRate bitRate = mf.BitRate
return format, bitRate return format, bitRate
} }
@@ -175,7 +194,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
bitRate = t.DefaultBitRate bitRate = t.DefaultBitRate
} }
} }
if format == mf.Suffix && bitRate >= mf.BitRate { if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample {
format = "raw" format = "raw"
bitRate = 0 bitRate = 0
} }
@@ -217,7 +236,16 @@ func NewTranscodingCache() TranscodingCache {
transcodingCtx = request.AddValues(context.Background(), ctx) transcodingCtx = request.AddValues(context.Background(), ctx)
} }
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset) out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
Command: t.Command,
Format: job.format,
FilePath: job.filePath,
BitRate: job.bitRate,
SampleRate: job.sampleRate,
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
})
if err != nil { if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid return nil, os.ErrInvalid

View File

@@ -26,42 +26,64 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() { It("returns raw if raw is requested", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
}) })
It("returns raw if a transcoder does not exists", func() { It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0) format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
}) })
It("returns the requested format if a transcoder exists", func() { It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3")) Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate Expect(bitRate).To(Equal(160)) // Default Bit Rate
}) })
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3" mf.Suffix = "mp3"
mf.BitRate = 112 mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128) format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
}) })
It("returns the requested format if requested BitRate is lower than original", func() { It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3" mf.Suffix = "mp3"
mf.BitRate = 320 mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
Expect(format).To(Equal("mp3")) Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192)) Expect(bitRate).To(Equal(192))
}) })
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() { It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
mf.Suffix = "mp3" mf.Suffix = "mp3"
mf.BitRate = 320 mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(320)) Expect(bitRate).To(Equal(320))
}) })
It("returns the format when same format is requested but with a lower sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 2118
mf.SampleRate = 96000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("flac"))
Expect(bitRate).To(Equal(0))
})
It("returns raw when same format is requested with same sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 48000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("raw"))
})
It("returns raw when same format is requested with no sample rate constraint", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 96000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0)
Expect(format).To(Equal("raw"))
})
Context("Downsampling", func() { Context("Downsampling", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Server.DefaultDownsamplingFormat = "opus" conf.Server.DefaultDownsamplingFormat = "opus"
@@ -69,13 +91,13 @@ var _ = Describe("MediaStreamer", func() {
mf.BitRate = 960 mf.BitRate = 960
}) })
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0)
Expect(format).To(Equal("opus")) Expect(format).To(Equal("opus"))
Expect(bitRate).To(Equal(128)) Expect(bitRate).To(Equal(128))
}) })
It("returns raw if maxBitrate is equal or greater than original", func() { It("returns raw if maxBitrate is equal or greater than original", func() {
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066 // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0)) Expect(bitRate).To(Equal(0))
}) })
@@ -90,34 +112,34 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() { It("returns raw if raw is requested", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
}) })
It("returns configured format/bitrate as default", func() { It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
Expect(format).To(Equal("oga")) Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96)) Expect(bitRate).To(Equal(96))
}) })
It("returns requested format", func() { It("returns requested format", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3")) Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate Expect(bitRate).To(Equal(160)) // Default Bit Rate
}) })
It("returns requested bitrate", func() { It("returns requested bitrate", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0)
Expect(format).To(Equal("oga")) Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80)) Expect(bitRate).To(Equal(80))
}) })
It("returns raw if selected bitrate and format is the same as original", func() { It("returns raw if selected bitrate and format is the same as original", func() {
mf.Suffix = "mp3" mf.Suffix = "mp3"
mf.BitRate = 192 mf.BitRate = 192
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0)) Expect(bitRate).To(Equal(0))
}) })
@@ -133,27 +155,27 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() { It("returns raw if raw is requested", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw")) Expect(format).To(Equal("raw"))
}) })
It("returns configured format/bitrate as default", func() { It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
Expect(format).To(Equal("oga")) Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(192)) Expect(bitRate).To(Equal(192))
}) })
It("returns requested format", func() { It("returns requested format", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3")) Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate Expect(bitRate).To(Equal(160)) // Default Bit Rate
}) })
It("returns requested bitrate", func() { It("returns requested bitrate", func() {
mf.Suffix = "flac" mf.Suffix = "flac"
mf.BitRate = 1000 mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160) format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0)
Expect(format).To(Equal("oga")) Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(160)) Expect(bitRate).To(Equal(160))
}) })

View File

@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() { Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() { It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0) s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue()) Expect(s.Seekable()).To(BeTrue())
}) })
It("returns a seekable stream if maxBitRate is 0", func() { It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0) s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue()) Expect(s.Seekable()).To(BeTrue())
}) })
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0) s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue()) Expect(s.Seekable()).To(BeTrue())
}) })
It("returns a NON seekable stream if transcode is required", func() { It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0) s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse()) Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0))) Expect(s.Duration()).To(Equal(float32(257.0)))
}) })
It("returns a seekable stream if the file is complete in the cache", func() { It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0) s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil()) Expect(err).To(BeNil())
_, _ = io.ReadAll(s) _, _ = io.ReadAll(s)
_ = s.Close() _ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0) s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue()) Expect(s.Seekable()).To(BeTrue())
}) })

View File

@@ -215,6 +215,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.BackupCount = conf.Server.Backup.Count data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel data.Config.DevActivityPanel = conf.Server.DevActivityPanel
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
@@ -265,6 +266,10 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil { if err != nil {
log.Trace(ctx, "Error reading active users count", err) log.Trace(ctx, "Error reading active users count", err)
} }
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
if err != nil {
log.Trace(ctx, "Error reading file suffixes count", err)
}
// Check for smart playlists // Check for smart playlists
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx) data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)

View File

@@ -40,12 +40,14 @@ type Data struct {
Libraries int64 `json:"libraries"` Libraries int64 `json:"libraries"`
ActiveUsers int64 `json:"activeUsers"` ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"` ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
} `json:"library"` } `json:"library"`
Config struct { Config struct {
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
LogFileConfigured bool `json:"logFileConfigured,omitempty"` LogFileConfigured bool `json:"logFileConfigured,omitempty"`
TLSConfigured bool `json:"tlsConfigured,omitempty"` TLSConfigured bool `json:"tlsConfigured,omitempty"`
ScannerEnabled bool `json:"scannerEnabled,omitempty"` ScannerEnabled bool `json:"scannerEnabled,omitempty"`
ScannerExtractor string `json:"scannerExtractor,omitempty"`
ScanSchedule string `json:"scanSchedule,omitempty"` ScanSchedule string `json:"scanSchedule,omitempty"`
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
ScanOnStartup bool `json:"scanOnStartup,omitempty"` ScanOnStartup bool `json:"scanOnStartup,omitempty"`

View File

@@ -3,6 +3,7 @@ package playback
import ( import (
"fmt" "fmt"
"math/rand" "math/rand"
"strings"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -21,11 +22,11 @@ func NewQueue() *Queue {
} }
func (pd *Queue) String() string { func (pd *Queue) String() string {
filenames := "" var filenames strings.Builder
for idx, item := range pd.Items { 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 // returns the current mediafile or nil

View File

@@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool {
return true return true
} }
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) 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 { if match, _ := doublestar.Match(path, rel); match {
return true return true
} }
@@ -179,7 +179,9 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx) mediaFileRepository := s.ds.MediaFile(ctx)
var mfs model.MediaFiles var mfs model.MediaFiles
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) { // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
filteredLines := make([]string, 0, len(lines)) filteredLines := make([]string, 0, len(lines))
for _, line := range lines { for _, line := range lines {
line := strings.TrimSpace(line) line := strings.TrimSpace(line)
@@ -191,8 +193,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.HasPrefix(line, "file://") { if after, ok := strings.CutPrefix(line, "file://"); ok {
line = strings.TrimPrefix(line, "file://") line = after
line, _ = url.QueryUnescape(line) line, _ = url.QueryUnescape(line)
} }
if !model.IsAudioFile(line) { if !model.IsAudioFile(line) {
@@ -206,33 +208,66 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
continue continue
} }
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD. // SQLite comparisons do not perform Unicode normalization, and filesystem normalization
// See https://github.com/navidrome/navidrome/issues/4663 // differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
resolvedPaths = slice.Map(resolvedPaths, func(path string) string { // Generate lookup candidates for both forms so playlist entries match DB paths regardless
return strings.ToLower(norm.NFD.String(path)) // of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
}) //
// We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
// only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
// (e.g., vs ) are not matched case-insensitively by NOCASE.
lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
seen := make(map[string]struct{}, len(resolvedPaths)*4)
for _, path := range resolvedPaths {
// Add original paths first (for exact matching of non-ASCII characters)
nfcRaw := norm.NFC.String(path)
if _, ok := seen[nfcRaw]; !ok {
seen[nfcRaw] = struct{}{}
lookupCandidates = append(lookupCandidates, nfcRaw)
}
nfdRaw := norm.NFD.String(path)
if _, ok := seen[nfdRaw]; !ok {
seen[nfdRaw] = struct{}{}
lookupCandidates = append(lookupCandidates, nfdRaw)
}
found, err := mediaFileRepository.FindByPaths(resolvedPaths) // Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
nfc := strings.ToLower(nfcRaw)
if _, ok := seen[nfc]; !ok {
seen[nfc] = struct{}{}
lookupCandidates = append(lookupCandidates, nfc)
}
nfd := strings.ToLower(nfdRaw)
if _, ok := seen[nfd]; !ok {
seen[nfd] = struct{}{}
lookupCandidates = append(lookupCandidates, nfd)
}
}
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
if err != nil { if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err) log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue continue
} }
// Build lookup map with library-qualified keys, normalized for comparison
// Build lookup map with library-qualified keys, normalized for comparison.
// Canonicalize to NFC so NFD/NFC become comparable.
existing := make(map[string]int, len(found)) existing := make(map[string]int, len(found))
for idx := range found { for idx := range found {
// Normalize to lowercase for case-insensitive comparison key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
// Key format: "libraryID:path"
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
existing[key] = idx existing[key] = idx
} }
// Find media files in the order of the resolved paths, to keep playlist order // Find media files in the order of the resolved paths, to keep playlist order
for _, path := range resolvedPaths { for _, path := range resolvedPaths {
idx, ok := existing[path] key := strings.ToLower(norm.NFC.String(path))
idx, ok := existing[key]
if ok { if ok {
mfs = append(mfs, found[idx]) mfs = append(mfs, found[idx])
} else { } else {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path) // Prefer logging a composed representation when possible to avoid confusing output
// with decomposed combining marks.
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path))
} }
} }
} }
@@ -394,7 +429,20 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx) owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) { if err != nil && !errors.Is(err, model.ErrNotFound) {
return err return err
} }
@@ -485,7 +533,7 @@ type nspFile struct {
} }
func (i *nspFile) UnmarshalJSON(data []byte) error { func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]interface{}{} m := map[string]any{}
err := json.Unmarshal(data, &m) err := json.Unmarshal(data, &m)
if err != nil { if err != nil {
return err return err

View File

@@ -135,6 +135,55 @@ var _ = Describe("Playlists", func() {
}) })
}) })
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
func(storedForm, filesystemForm string) {
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
plsNameNFD := norm.NFD.String(plsNameNFC)
Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ
nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
storedName := nameByForm[storedForm]
filesystemName := nameByForm[filesystemForm]
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = core.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
// Pre-populate mock repo with the stored normalization form
storedPath := tmpDir + "/" + storedName + ".m3u"
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: storedPath,
Sync: true,
}
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
// Import using the filesystem's normalization form
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u")
Expect(err).ToNot(HaveOccurred())
// Should update existing playlist, not create new one
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Name).To(Equal("Existing Playlist"))
},
Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
)
Describe("Cross-library relative paths", func() { Describe("Cross-library relative paths", func() {
var tmpDir, plsDir, songsDir string var tmpDir, plsDir, songsDir string
@@ -446,23 +495,79 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
}) })
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() { // Fullwidth characters (e.g., ) are not handled by SQLite's NOCASE collation,
// Simulate macOS filesystem: stores paths in NFD (decomposed) form // so we need exact matching for non-ASCII characters.
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave // Fullwidth uppercase (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33)
repo.data = []string{nfdPath} repo.data = []string{
"plex/02 - .flac",
// Simulate Apple Music M3U: uses NFC (composed) form }
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character m3u := "/music/plex/02 - .flac\n"
m3u := nfcPath + "\n"
f := strings.NewReader(m3u) f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f) pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1)) Expect(pls.Tracks).To(HaveLen(1))
// Should match despite different Unicode normalization forms Expect(pls.Tracks[0].Path).To(Equal("plex/02 - .flac"))
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
}) })
// Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms
// macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form.
DescribeTable("matches paths across Unicode NFC/NFD normalization",
func(description, pathNFC string, dbForm, playlistForm norm.Form) {
pathNFD := norm.NFD.String(pathNFC)
Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
// Set up DB with specified normalization form
var dbPath string
if dbForm == norm.NFC {
dbPath = pathNFC
} else {
dbPath = pathNFD
}
repo.data = []string{dbPath}
// Set up playlist with specified normalization form
var playlistPath string
if playlistForm == norm.NFC {
playlistPath = pathNFC
} else {
playlistPath = pathNFD
}
m3u := "/music/" + playlistPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal(dbPath))
},
// French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300)
Entry("French diacritics - DB:NFD, playlist:NFC",
"macOS DB with Apple Music playlist",
"artist/Michèle/song.mp3", norm.NFD, norm.NFC),
// Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099)
Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
"Linux/Windows DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
"macOS DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
// Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306)
Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
"macOS DB with NFC playlist",
"Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC),
// Polish: ó (U+00F3) decomposes to o + combining acute (U+0301)
Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
"macOS DB with NFC playlist",
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC),
Entry("Polish diacritics - DB:NFC, playlist:NFD",
"Linux/Windows DB with macOS-exported playlist",
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD),
)
}) })
Describe("InPlaylistsPath", func() { Describe("InPlaylistsPath", func() {
@@ -563,9 +668,6 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
var mfs model.MediaFiles var mfs model.MediaFiles
for idx, dataPath := range r.data { for idx, dataPath := range r.data {
// Normalize the data path to NFD (simulates macOS filesystem storage)
normalizedDataPath := norm.NFD.String(dataPath)
for _, requestPath := range paths { for _, requestPath := range paths {
// Strip library qualifier if present (format: "libraryID:path") // Strip library qualifier if present (format: "libraryID:path")
actualPath := requestPath actualPath := requestPath
@@ -577,12 +679,9 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
} }
} }
// The request path should already be normalized to NFD by production code // Case-insensitive comparison (like SQL's "collate nocase"), but with no
// before calling FindByPaths (to match DB storage) // implicit Unicode normalization (SQLite does not normalize NFC/NFD).
normalizedRequestPath := norm.NFD.String(actualPath) if strings.EqualFold(actualPath, dataPath) {
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
mfs = append(mfs, model.MediaFile{ mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx), ID: strconv.Itoa(idx),
Path: dataPath, // Return original path from DB Path: dataPath, // Return original path from DB
@@ -597,10 +696,16 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
type mockedPlaylistRepo struct { type mockedPlaylistRepo struct {
last *model.Playlist last *model.Playlist
data map[string]*model.Playlist // keyed by path
model.PlaylistRepository model.PlaylistRepository
} }
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) { func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if r.data != nil {
if pls, ok := r.data[path]; ok {
return pls, nil
}
}
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }

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, // Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL. // remaining is set to 0 to avoid negative TTL.
remaining := int(mf.Duration) - position remaining := max(int(mf.Duration)-position, 0)
if remaining < 0 {
remaining = 0
}
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl) _ = 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) s := entity.(*model.Share)
id, err := r.newId() id, err := r.newId()
if err != nil { if err != nil {
@@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return id, err 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"} cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration // TODO Better handling of Share expiration

View File

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

87
core/transcode/aliases.go Normal file
View File

@@ -0,0 +1,87 @@
package transcode
import (
"slices"
"strings"
)
// containerAliasGroups maps each container alias to a canonical group name.
var containerAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
{"mpeg", "mp3", "mp2"},
{"ogg", "oga"},
{"aif", "aiff"},
{"asf", "wma"},
{"mpc", "mpp"},
{"wv"},
}
m := make(map[string]string)
for _, g := range groups {
canonical := g[0]
for _, name := range g {
m[name] = canonical
}
}
return m
}()
// codecAliasGroups maps each codec alias to a canonical group name.
// Codecs within the same group are considered equivalent.
var codecAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts"},
{"ac3", "ac-3"},
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
{"mpc7", "musepack7"},
{"mpc8", "musepack8"},
{"wma1", "wmav1"},
{"wma2", "wmav2"},
{"wmalossless", "wma9lossless"},
{"wmapro", "wma9pro"},
{"shn", "shorten"},
{"mp4als", "als"},
}
m := make(map[string]string)
for _, g := range groups {
for _, name := range g {
m[name] = g[0] // canonical = first entry
}
}
return m
}()
// matchesWithAliases checks if a value matches any entry in candidates,
// consulting the alias map for equivalent names.
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
value = strings.ToLower(value)
canonical := aliases[value]
for _, c := range candidates {
c = strings.ToLower(c)
if c == value {
return true
}
if canonical != "" && aliases[c] == canonical {
return true
}
}
return false
}
// matchesContainer checks if a file suffix matches any of the container names,
// including common aliases.
func matchesContainer(suffix string, containers []string) bool {
return matchesWithAliases(suffix, containers, containerAliasGroups)
}
// matchesCodec checks if a codec matches any of the codec names,
// including common aliases.
func matchesCodec(codec string, codecs []string) bool {
return matchesWithAliases(codec, codecs, codecAliasGroups)
}
func containsIgnoreCase(slice []string, s string) bool {
return slices.ContainsFunc(slice, func(item string) bool {
return strings.EqualFold(item, s)
})
}

59
core/transcode/codec.go Normal file
View File

@@ -0,0 +1,59 @@
package transcode
import "strings"
// isLosslessFormat returns true if the format is a lossless audio codec/format.
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
// ffmpeg can produce as output (a smaller set). This function covers all known lossless formats
// for transcoding decision purposes.
func isLosslessFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd", "pcm":
return true
}
return false
}
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
// it differently than PCM. Currently handles DSD (÷8):
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
// For other codecs, returns the rate unchanged.
func normalizeSourceSampleRate(sampleRate int, codec string) int {
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
return sampleRate / 8
}
return sampleRate
}
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
// what ffmpeg produces). For other codecs, returns the depth unchanged.
func normalizeSourceBitDepth(bitDepth int, codec string) int {
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
return 24
}
return bitDepth
}
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
// Returns 0 if the codec has no fixed output rate.
func codecFixedOutputSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "opus":
return 48000
}
return 0
}
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
// Returns 0 if the codec has no hard limit.
func codecMaxSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "mp3":
return 48000
case "aac":
return 96000
}
return 0
}

View File

@@ -0,0 +1,206 @@
package transcode
import (
"strconv"
"strings"
"github.com/navidrome/navidrome/model"
)
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
type adjustResult int
const (
adjustNone adjustResult = iota // Value already satisfies the limitation
adjustAdjusted // Value was changed to fit the limitation
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
)
// checkLimitations checks codec profile limitations against source media.
// Returns "" if all limitations pass, or a typed reason string for the first failure.
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
for _, lim := range limitations {
var ok bool
var reason string
switch lim.Name {
case LimitationAudioChannels:
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
reason = "audio channels not supported"
case LimitationAudioSamplerate:
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
reason = "audio samplerate not supported"
case LimitationAudioBitrate:
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
reason = "audio bitrate not supported"
case LimitationAudioBitdepth:
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
reason = "audio bitdepth not supported"
case LimitationAudioProfile:
// TODO: populate source profile when MediaFile has audio profile info
ok = checkStringLimitation("", lim.Comparison, lim.Values)
reason = "audio profile not supported"
default:
continue
}
if !ok && lim.Required {
return reason
}
}
return ""
}
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
// Returns the adjustment result.
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
switch lim.Name {
case LimitationAudioChannels:
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
case LimitationAudioBitrate:
current := ts.Bitrate
if current == 0 {
current = sourceBitrate
}
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
case LimitationAudioSamplerate:
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
case LimitationAudioBitdepth:
if ts.BitDepth > 0 {
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
}
case LimitationAudioProfile:
// TODO: implement when audio profile data is available
}
return adjustNone
}
// applyIntLimitation applies a limitation comparison to a value.
// If the value needs adjusting, calls the setter and returns the result.
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
if len(values) == 0 {
return adjustNone
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current <= limit {
return adjustNone
}
setter(limit)
return adjustAdjusted
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current >= limit {
return adjustNone
}
// Cannot upscale
return adjustCannotFit
case ComparisonEquals:
// Check if current value matches any allowed value
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustNone
}
}
// Find the closest allowed value below current (don't upscale)
var closest int
found := false
for _, v := range values {
if limit, ok := parseInt(v); ok && limit < current {
if !found || limit > closest {
closest = limit
found = true
}
}
}
if found {
setter(closest)
return adjustAdjusted
}
return adjustCannotFit
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustCannotFit
}
}
return adjustNone
}
return adjustNone
}
func checkIntLimitation(value int, comparison string, values []string) bool {
if len(values) == 0 {
return true
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value <= limit
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value >= limit
case ComparisonEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return false
}
}
return true
}
return true
}
// checkStringLimitation checks a string value against a limitation.
// Only Equals and NotEquals comparisons are meaningful for strings.
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
func checkStringLimitation(value string, comparison string, values []string) bool {
switch comparison {
case ComparisonEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return false
}
}
return true
}
return true
}
func parseInt(s string) (int, bool) {
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return 0, false
}
return v, true
}

400
core/transcode/transcode.go Normal file
View File

@@ -0,0 +1,400 @@
package transcode
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const (
tokenTTL = 12 * time.Hour
defaultBitrate = 256 // kbps
// JWT claim keys for transcode params tokens
claimMediaID = "mid" // Media file ID
claimDirectPlay = "dp" // Direct play flag (bool)
claimUpdatedAt = "ua" // Source file updated-at (Unix seconds)
claimFormat = "fmt" // Target transcoding format
claimBitrate = "br" // Target bitrate (kbps)
claimChannels = "ch" // Target channels
claimSampleRate = "sr" // Target sample rate (Hz)
claimBitDepth = "bd" // Target bit depth
)
func NewDecider(ds model.DataStore) Decider {
return &deciderService{
ds: ds,
}
}
type deciderService struct {
ds model.DataStore
}
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
decision := &Decision{
MediaID: mf.ID,
SourceUpdatedAt: mf.UpdatedAt,
}
sourceBitrate := mf.BitRate // kbps
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
// Build source stream details
decision.SourceStream = buildSourceStream(mf)
// Check global bitrate constraint first.
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
// Skip direct play profiles entirely — global constraint fails
} else {
// Try direct play profiles, collecting reasons for each failure
for _, profile := range clientInfo.DirectPlayProfiles {
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
decision.CanDirectPlay = true
decision.TranscodeReasons = nil // Clear any previously collected reasons
break
} else {
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
}
}
}
// If direct play is possible, we're done
if decision.CanDirectPlay {
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
return decision, nil
}
// Try transcoding profiles (in order of preference)
for _, profile := range clientInfo.TranscodingProfiles {
if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
decision.CanTranscode = true
decision.TargetFormat = transcodeFormat
decision.TargetBitrate = ts.Bitrate
decision.TargetChannels = ts.Channels
decision.TargetSampleRate = ts.SampleRate
decision.TargetBitDepth = ts.BitDepth
decision.TranscodeStream = ts
break
}
}
if decision.CanTranscode {
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
}
// If neither direct play nor transcode is possible
if !decision.CanDirectPlay && !decision.CanTranscode {
decision.ErrorReason = "no compatible playback profile found"
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
}
return decision, nil
}
func buildSourceStream(mf *model.MediaFile) StreamDetails {
return StreamDetails{
Container: mf.Suffix,
Codec: mf.AudioCodec(),
Bitrate: mf.BitRate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
Channels: mf.Channels,
Duration: mf.Duration,
Size: mf.Size,
IsLossless: mf.IsLossless(),
}
}
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
// or a typed reason string if it doesn't match.
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
// Check protocol (only http for now)
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
return "protocol not supported"
}
// Check container
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
return "container not supported"
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
return "audio codec not supported"
}
// Check channels
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
return "audio channels not supported"
}
// Check codec-specific limitations
for _, codecProfile := range clientInfo.CodecProfiles {
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
return reason
}
}
}
return ""
}
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
// Returns the stream details and the internal transcoding format (which may differ from the
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
// Returns nil, "" if the profile cannot produce a valid output.
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) {
// Check protocol (only http for now)
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
return nil, ""
}
responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile)
if targetFormat == "" {
return nil, ""
}
targetIsLossless := isLosslessFormat(targetFormat)
// Reject lossy to lossless conversion
if !mf.IsLossless() && targetIsLossless {
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
return nil, ""
}
ts := &StreamDetails{
Container: responseContainer,
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()),
Channels: mf.Channels,
BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()),
IsLossless: targetIsLossless,
}
if ts.Codec == "" {
ts.Codec = targetFormat
}
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
ts.SampleRate = fixedRate
}
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
ts.SampleRate = maxRate
}
// Determine target bitrate (all in kbps)
if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
// Apply MaxAudioChannels from the transcoding profile
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
ts.Channels = profile.MaxAudioChannels
}
// Apply codec profile limitations to the TARGET codec
if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
return ts, targetFormat
}
// resolveTargetFormat determines the response container and internal target format
// by looking up transcoding configs. Returns ("", "") if no config found.
func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profile) (responseContainer, targetFormat string) {
responseContainer = strings.ToLower(profile.Container)
targetFormat = responseContainer
if targetFormat == "" {
targetFormat = strings.ToLower(profile.AudioCodec)
responseContainer = targetFormat
}
// Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac").
_, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) {
codec := strings.ToLower(profile.AudioCodec)
log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec)
_, err = s.ds.Transcoding(ctx).FindByFormat(codec)
if err == nil {
targetFormat = codec
}
}
if err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err)
} else {
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
}
return "", ""
}
return responseContainer, targetFormat
}
// computeBitrate determines the target bitrate for the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
if mf.IsLossless() {
if !targetIsLossless {
if clientInfo.MaxTranscodingAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
} else {
ts.Bitrate = defaultBitrate
}
} else {
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
return false
}
}
} else {
ts.Bitrate = sourceBitrate
}
// Apply maxAudioBitrate as final cap
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
ts.Bitrate = clientInfo.MaxAudioBitrate
}
return true
}
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
targetCodec := ts.Codec
for _, codecProfile := range clientInfo.CodecProfiles {
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
continue
}
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
continue
}
for _, lim := range codecProfile.Limitations {
result := applyLimitation(sourceBitrate, &lim, ts)
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
return false
}
if result == adjustCannotFit {
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
"comparison", lim.Comparison, "values", lim.Values)
return false
}
}
}
return true
}
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
exp := time.Now().Add(tokenTTL)
claims := map[string]any{
claimMediaID: decision.MediaID,
claimDirectPlay: decision.CanDirectPlay,
claimUpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
}
if decision.CanTranscode && decision.TargetFormat != "" {
claims[claimFormat] = decision.TargetFormat
claims[claimBitrate] = decision.TargetBitrate
if decision.TargetChannels > 0 {
claims[claimChannels] = decision.TargetChannels
}
if decision.TargetSampleRate > 0 {
claims[claimSampleRate] = decision.TargetSampleRate
}
if decision.TargetBitDepth > 0 {
claims[claimBitDepth] = decision.TargetBitDepth
}
}
return auth.CreateExpiringPublicToken(exp, claims)
}
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
claims, err := auth.Validate(token)
if err != nil {
return nil, err
}
params := &Params{}
// Required claims
mid, ok := claims[claimMediaID].(string)
if !ok || mid == "" {
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
}
params.MediaID = mid
dp, ok := claims[claimDirectPlay].(bool)
if !ok {
return nil, fmt.Errorf("%w: invalid transcode token: missing direct play flag", ErrTokenInvalid)
}
params.DirectPlay = dp
// Optional claims (legitimately absent for direct-play tokens)
if f, ok := claims[claimFormat].(string); ok {
params.TargetFormat = f
}
if br, ok := claims[claimBitrate].(float64); ok {
params.TargetBitrate = int(br)
}
if ch, ok := claims[claimChannels].(float64); ok {
params.TargetChannels = int(ch)
}
if sr, ok := claims[claimSampleRate].(float64); ok {
params.TargetSampleRate = int(sr)
}
if bd, ok := claims[claimBitDepth].(float64); ok {
params.TargetBitDepth = int(bd)
}
ua, ok := claims[claimUpdatedAt].(float64)
if !ok {
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
}
params.SourceUpdatedAt = time.Unix(int64(ua), 0)
return params, nil
}
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
params, err := s.ParseTranscodeParams(token)
if err != nil {
return nil, nil, errors.Join(ErrTokenInvalid, err)
}
if params.MediaID != mediaID {
return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID)
}
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, nil, ErrMediaNotFound
}
return nil, nil, err
}
if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) {
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
"tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
return nil, nil, ErrTokenStale
}
return params, mf, nil
}

View File

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

View File

File diff suppressed because it is too large Load Diff

140
core/transcode/types.go Normal file
View File

@@ -0,0 +1,140 @@
package transcode
import (
"context"
"errors"
"time"
"github.com/navidrome/navidrome/model"
)
var (
ErrTokenInvalid = errors.New("invalid or expired transcode token")
ErrMediaNotFound = errors.New("media file not found")
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
)
// Decider is the core service interface for making transcoding decisions
type Decider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
CreateTranscodeParams(decision *Decision) (string, error)
ParseTranscodeParams(token string) (*Params, error)
ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error)
}
// ClientInfo represents client playback capabilities.
// All bitrate values are in kilobits per second (kbps)
type ClientInfo struct {
Name string
Platform string
MaxAudioBitrate int
MaxTranscodingAudioBitrate int
DirectPlayProfiles []DirectPlayProfile
TranscodingProfiles []Profile
CodecProfiles []CodecProfile
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string
AudioCodecs []string
Protocols []string
MaxAudioChannels int
}
// Profile describes a transcoding target the client supports
type Profile struct {
Container string
AudioCodec string
Protocol string
MaxAudioChannels int
}
// CodecProfile describes codec-specific limitations
type CodecProfile struct {
Type string
Name string
Limitations []Limitation
}
// Limitation describes a specific codec limitation
type Limitation struct {
Name string
Comparison string
Values []string
Required bool
}
// Protocol values (OpenSubsonic spec enum)
const (
ProtocolHTTP = "http"
ProtocolHLS = "hls"
)
// Comparison operators (OpenSubsonic spec enum)
const (
ComparisonEquals = "Equals"
ComparisonNotEquals = "NotEquals"
ComparisonLessThanEqual = "LessThanEqual"
ComparisonGreaterThanEqual = "GreaterThanEqual"
)
// Limitation names (OpenSubsonic spec enum)
const (
LimitationAudioChannels = "audioChannels"
LimitationAudioBitrate = "audioBitrate"
LimitationAudioProfile = "audioProfile"
LimitationAudioSamplerate = "audioSamplerate"
LimitationAudioBitdepth = "audioBitdepth"
)
// Codec profile types (OpenSubsonic spec enum)
const (
CodecProfileTypeAudio = "AudioCodec"
)
// Decision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type Decision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceStream StreamDetails
SourceUpdatedAt time.Time
TranscodeStream *StreamDetails
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails struct {
Container string
Codec string
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
Bitrate int
SampleRate int
BitDepth int
Channels int
Duration float32
Size int64
IsLossless bool
}
// Params contains the parameters extracted from a transcode token.
// TargetBitrate is in kilobits per second (kbps).
type Params struct {
MediaID string
DirectPlay bool
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceUpdatedAt time.Time
}

View File

@@ -50,12 +50,12 @@ type userRepositoryWrapper struct {
} }
// Save implements rest.Persistable by delegating to the underlying repository. // 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) return r.UserRepository.(rest.Persistable).Save(entity)
} }
// Update implements rest.Persistable by delegating to the underlying repository. // 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...) return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
} }

View File

@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
) )
var Set = wire.NewSet( var Set = wire.NewSet(
@@ -20,6 +21,7 @@ var Set = wire.NewSet(
NewLibrary, NewLibrary,
NewUser, NewUser,
NewMaintenance, NewMaintenance,
transcode.NewDecider,
agents.GetAgents, agents.GetAgents,
external.NewProvider, external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)), wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -126,7 +126,7 @@ func Optimize(ctx context.Context) {
} }
log.Debug(ctx, "Optimizing open connections", "numConns", numConns) log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn var conns []*sql.Conn
for i := 0; i < numConns; i++ { for range numConns {
conn, err := Db().Conn(ctx) conn, err := Db().Conn(ctx)
conns = append(conns, conn) conns = append(conns, conn)
if err != nil { if err != nil {
@@ -147,8 +147,8 @@ func Optimize(ctx context.Context) {
type statusLogger struct{ numPending int } type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) { func (l *statusLogger) Printf(format string, v ...any) {
if len(v) < 1 { if len(v) < 1 {
return return
} }
@@ -183,27 +183,27 @@ type logAdapter struct {
silent bool silent bool
} }
func (l *logAdapter) Fatal(v ...interface{}) { func (l *logAdapter) Fatal(v ...any) {
log.Fatal(l.ctx, fmt.Sprint(v...)) 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...)) log.Fatal(l.ctx, fmt.Sprintf(format, v...))
} }
func (l *logAdapter) Print(v ...interface{}) { func (l *logAdapter) Print(v ...any) {
if !l.silent { if !l.silent {
log.Info(l.ctx, fmt.Sprint(v...)) log.Info(l.ctx, fmt.Sprint(v...))
} }
} }
func (l *logAdapter) Println(v ...interface{}) { func (l *logAdapter) Println(v ...any) {
if !l.silent { if !l.silent {
log.Info(l.ctx, fmt.Sprintln(v...)) 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 { if !l.silent {
log.Info(l.ctx, fmt.Sprintf(format, v...)) log.Info(l.ctx, fmt.Sprintf(format, v...))
} }

View File

@@ -0,0 +1,23 @@
-- +goose Up
ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-- Populate average_rating from existing ratings
UPDATE album SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
0
);
UPDATE media_file SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
0
);
UPDATE artist SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
0
);
-- +goose Down
ALTER TABLE artist DROP COLUMN average_rating;
ALTER TABLE media_file DROP COLUMN average_rating;
ALTER TABLE album DROP COLUMN average_rating;

View File

@@ -0,0 +1,63 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
}
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
// Add codec column to media_file.
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
if err != nil {
return err
}
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
// Only affects users who still have the unmodified old default command.
_, err = tx.Exec(
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
)
if err != nil {
return err
}
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
var count int
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), "flac audio", "flac", 0,
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
)
if err != nil {
return err
}
}
return nil
}
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
return err
}

43
go.mod
View File

@@ -2,14 +2,19 @@ module github.com/navidrome/navidrome
go 1.25 go 1.25
// Fork to fix https://github.com/navidrome/navidrome/issues/3254 replace (
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d // Fork to fix https://github.com/navidrome/navidrome/issues/3254
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
)
require ( require (
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3 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/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -23,7 +28,7 @@ require (
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1 github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0 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/cors v1.2.2
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
@@ -44,8 +49,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.0 github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0 github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
@@ -53,20 +58,22 @@ require (
github.com/rjeczalik/notify v0.9.3 github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.11.0 github.com/tetratelabs/wazero v1.11.0
github.com/unrolled/secure v1.17.0 github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
golang.org/x/image v0.35.0 golang.org/x/image v0.36.0
golang.org/x/net v0.49.0 golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0 golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0 golang.org/x/sys v0.41.0
golang.org/x/term v0.39.0 golang.org/x/term v0.40.0
golang.org/x/text v0.33.0 golang.org/x/text v0.34.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -91,7 +98,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 // indirect github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/google/subcommands v1.2.0 // indirect github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -127,16 +134,16 @@ require (
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // 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.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // 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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 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 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -36,6 +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/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 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= 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/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -54,6 +56,8 @@ github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
@@ -73,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-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 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= 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.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 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= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@@ -106,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-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 h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 h1:oKBqR+eQXiIM7X8K1JEg9aoTEePLq/c6Awe484abOuA= github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -193,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/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 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= 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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -234,13 +238,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -269,7 +275,6 @@ github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRci
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -296,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/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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 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.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 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 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= 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= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -314,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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= 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-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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -339,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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -358,7 +363,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -366,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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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-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-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -379,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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -391,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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -402,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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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= 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/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

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

View File

@@ -3,12 +3,13 @@ package model
import "time" import "time"
type Annotations struct { type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" ` Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" ` RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" ` Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
} }
type AnnotatedRepository interface { type AnnotatedRepository interface {

View File

@@ -41,7 +41,7 @@ type DataStore interface {
Scrobble(ctx context.Context) ScrobbleRepository Scrobble(ctx context.Context) ScrobbleRepository
Plugin(ctx context.Context) PluginRepository 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 WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(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? // 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) ar, err := ds.Artist(ctx).Get(id)
if err == nil { if err == nil {
return ar, nil return ar, nil

View File

@@ -14,6 +14,7 @@ import (
"github.com/gohugoio/hashstructure" "github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
confmime "github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
@@ -56,6 +57,7 @@ type MediaFile struct {
SampleRate int `structs:"sample_rate" json:"sampleRate"` SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"` BitDepth int `structs:"bit_depth" json:"bitDepth"`
Channels int `structs:"channels" json:"channels"` Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
Genre string `structs:"genre" json:"genre"` Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"` Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@@ -140,7 +142,7 @@ func (mf MediaFile) Hash() string {
} }
hash, _ := hashstructure.Hash(mf, opts) hash, _ := hashstructure.Hash(mf, opts)
sum := md5.New() 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.Tags.Hash())
sum.Write(mf.Participants.Hash()) sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil)) return fmt.Sprintf("%x", sum.Sum(nil))
@@ -161,6 +163,81 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path) return filepath.Join(mf.LibraryPath, mf.Path)
} }
// AudioCodec returns the audio codec for this file.
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
func (mf MediaFile) AudioCodec() string {
// If we have a stored codec from scanning, normalize and return it
if mf.Codec != "" {
return strings.ToLower(mf.Codec)
}
// Fallback: infer from Suffix + BitDepth
return mf.inferCodecFromSuffix()
}
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
func (mf MediaFile) inferCodecFromSuffix() string {
switch strings.ToLower(mf.Suffix) {
case "mp3", "mpga":
return "mp3"
case "mp2":
return "mp2"
case "ogg", "oga":
return "vorbis"
case "opus":
return "opus"
case "mpc":
return "mpc"
case "wma":
return "wma"
case "flac":
return "flac"
case "wav":
return "pcm"
case "aif", "aiff", "aifc":
return "pcm"
case "ape":
return "ape"
case "wv", "wvp":
return "wv"
case "tta":
return "tta"
case "tak":
return "tak"
case "shn":
return "shn"
case "dsf", "dff":
return "dsd"
case "m4a":
// AAC if BitDepth==0, ALAC if BitDepth>0
if mf.BitDepth > 0 {
return "alac"
}
return "aac"
case "m4b", "m4p", "m4r":
return "aac"
default:
return ""
}
}
// IsLossless returns true if this file uses a lossless codec.
func (mf MediaFile) IsLossless() bool {
codec := mf.AudioCodec()
// Primary: codec-based check (most accurate for containers like M4A)
switch codec {
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
return true
}
// Secondary: suffix-based check using configurable list from YAML
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
return true
}
// Fallback heuristic: if BitDepth is set, it's likely lossless.
// This may produce false positives for lossy formats that report bit depth,
// but it becomes irrelevant once the Codec column is populated after a full rescan.
return mf.BitDepth > 0
}
type MediaFiles []MediaFile type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection. // ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
@@ -353,11 +430,13 @@ type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface { type MediaFileRepository interface {
CountAll(options ...QueryOptions) (int64, error) CountAll(options ...QueryOptions) (int64, error)
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
Exists(id string) (bool, error) Exists(id string) (bool, error)
Put(m *MediaFile) error Put(m *MediaFile) error
Get(id string) (*MediaFile, error) Get(id string) (*MediaFile, error)
GetWithParticipants(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error) GetAll(options ...QueryOptions) (MediaFiles, error)
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
GetCursor(options ...QueryOptions) (MediaFileCursor, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error)
Delete(id string) error Delete(id string) error
DeleteMissing(ids []string) error DeleteMissing(ids []string) error

View File

@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true conf.Server.EnableMediaFileCoverArt = true
}) })
Describe(".CoverArtId()", func() { Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() { It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID() id := mf.CoverArtID()
@@ -496,6 +496,94 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID)) Expect(id.ID).To(Equal(mf.AlbumID))
}) })
}) })
Describe("AudioCodec", func() {
It("returns normalized stored codec when available", func() {
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("aac"))
})
It("returns stored codec lowercased", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
DescribeTable("infers codec from suffix when Codec field is empty",
func(suffix string, bitDepth int, expected string) {
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
Expect(mf.AudioCodec()).To(Equal(expected))
},
Entry("mp3", "mp3", 0, "mp3"),
Entry("mpga", "mpga", 0, "mp3"),
Entry("mp2", "mp2", 0, "mp2"),
Entry("ogg", "ogg", 0, "vorbis"),
Entry("oga", "oga", 0, "vorbis"),
Entry("opus", "opus", 0, "opus"),
Entry("mpc", "mpc", 0, "mpc"),
Entry("wma", "wma", 0, "wma"),
Entry("flac", "flac", 0, "flac"),
Entry("wav", "wav", 0, "pcm"),
Entry("aif", "aif", 0, "pcm"),
Entry("aiff", "aiff", 0, "pcm"),
Entry("aifc", "aifc", 0, "pcm"),
Entry("ape", "ape", 0, "ape"),
Entry("wv", "wv", 0, "wv"),
Entry("wvp", "wvp", 0, "wv"),
Entry("tta", "tta", 0, "tta"),
Entry("tak", "tak", 0, "tak"),
Entry("shn", "shn", 0, "shn"),
Entry("dsf", "dsf", 0, "dsd"),
Entry("dff", "dff", 0, "dsd"),
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
Entry("m4b", "m4b", 0, "aac"),
Entry("m4p", "m4p", 0, "aac"),
Entry("m4r", "m4r", 0, "aac"),
Entry("unknown suffix", "xyz", 0, ""),
)
It("prefers stored codec over suffix inference", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
Describe("IsLossless", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("detects lossless codecs",
func(codec string, suffix string, bitDepth int, expected bool) {
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
Expect(mf.IsLossless()).To(Equal(expected))
},
Entry("flac", "FLAC", "flac", 16, true),
Entry("alac", "ALAC", "m4a", 24, true),
Entry("pcm via wav", "", "wav", 16, true),
Entry("pcm via aiff", "", "aiff", 24, true),
Entry("ape", "", "ape", 16, true),
Entry("wv", "", "wv", 0, true),
Entry("tta", "", "tta", 0, true),
Entry("tak", "", "tak", 0, true),
Entry("shn", "", "shn", 0, true),
Entry("dsd", "", "dsf", 0, true),
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
Entry("aac is lossy", "AAC", "m4a", 0, false),
Entry("vorbis is lossy", "", "ogg", 0, false),
Entry("opus is lossy", "", "opus", 0, false),
)
It("detects lossless via BitDepth fallback when codec is unknown", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
Expect(mf.IsLossless()).To(BeTrue())
})
It("returns false for unknown with no BitDepth", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
Expect(mf.IsLossless()).To(BeFalse())
})
})
}) })
func t(v string) time.Time { func t(v string) time.Time {

View File

@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath() mf.Path = md.FilePath()
mf.Suffix = md.Suffix() mf.Suffix = md.Suffix()
mf.Size = md.Size() mf.Size = md.Size()

View File

@@ -35,6 +35,7 @@ type AudioProperties struct {
BitDepth int BitDepth int
SampleRate int SampleRate int
Channels int Channels int
Codec string
} }
type Date string type Date string
@@ -250,7 +251,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
id3Base := parseID3Pairs(name, lowered) id3Base := parseID3Pairs(name, lowered)
if len(aliasValues) > 0 { 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 return id3Base
} }
@@ -260,8 +269,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
prefix := string(name) + ":" prefix := string(name) + ":"
for tagKey, tagValues := range lowered { for tagKey, tagValues := range lowered {
keyStr := string(tagKey) keyStr := string(tagKey)
if strings.HasPrefix(keyStr, prefix) { if after, ok := strings.CutPrefix(keyStr, prefix); ok {
keyPart := strings.TrimPrefix(keyStr, prefix) keyPart := after
if keyPart == string(name) { if keyPart == string(name) {
keyPart = "" keyPart = ""
} }

View File

@@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() {
metadata.NewPair("eng", "Lyrics"), 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() { Describe("ReplayGain", func() {

View File

@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils"
@@ -26,10 +27,14 @@ type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibI
func createGetPID(hash hashFunc) getPIDFunc { func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc var getPID getPIDFunc
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string { getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
attr = strings.TrimSpace(strings.ToLower(attr)) attr = strings.TrimSpace(strings.ToLower(attr))
switch attr { switch attr {
case "albumid": case "albumid":
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
return getPID(mf, md, conf.Server.PID.Album, prependLibId) return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder": case "folder":
return filepath.Dir(mf.Path) return filepath.Dir(mf.Path)
@@ -44,12 +49,12 @@ func createGetPID(hash hashFunc) getPIDFunc {
} }
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := "" pid := ""
fields := strings.Split(spec, "|") fields := strings.SplitSeq(spec, "|")
for _, field := range fields { for field := range fields {
attributes := strings.Split(field, ",") attributes := strings.Split(field, ",")
hasValue := false hasValue := false
values := slice.Map(attributes, func(attr string) string { values := slice.Map(attributes, func(attr string) string {
v := getAttr(mf, md, attr, prependLibId) v := getAttr(mf, md, attr, prependLibId, spec)
if v != "" { if v != "" {
hasValue = true hasValue = true
} }

View File

@@ -114,6 +114,24 @@ var _ = Describe("getPID", func() {
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)")) Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
}) })
}) })
When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{
"album": {"Album Name"},
"albumversion": {"Version"},
"releasedate": {"2022"},
}
// Should not panic and return a valid PID ignoring the recursive "albumid"
Expect(func() {
pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
}).To(Not(Panic()))
})
})
}) })
Context("edge cases", func() { Context("edge cases", func() {

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