Compare commits

...

45 Commits

Author SHA1 Message Date
Deluan
735c0d9103 chore(deps): remove direct dependency on golang.org/x/exp
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:03:44 -05:00
Deluan
fc9817552d fix(subsonic): make getUser?username comparison case-insensitive
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 17:56:40 -05:00
Xabi
0c1b65d3e6 fix(ui): update Basque translation (#4815)
Added missing strings and a fix or two
2025-12-19 08:32:13 -05:00
Deluan
47b448c64f chore(deps): update action versions in pipeline configuration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:30:18 -05:00
Deluan
834fa494e4 chore(deps): update golangci-lint to v2.7.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:25:51 -05:00
Deluan
5d34640065 chore(deps): update dependencies for maruel/natural to v1.3.0 and tetratelabs/wazero to v1.11.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-19 08:24:45 -05:00
Deluan
9ed309ac81 feat(scanner): implement file-based target passing for large target lists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 16:08:32 -05:00
Deluan
8c80be56da fix(scanner): ensure FullScanInProgress reflects current scan request during interrupted scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 12:16:00 -05:00
Deluan
cde5992c46 fix(scanner): execute GetFolderUpdateInfo in batches to avoid "Expression tree is too large (maximum depth 1000)"
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 11:37:13 -05:00
Deluan
017676c457 fix(ui): export all missing files instead of first 1000
Fixes #4721
2025-12-16 06:43:02 -05:00
Deluan
2d7b716834 fix(scanner): remove stale role associations when artist role changes. Fix #4242
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-16 06:38:50 -05:00
Deluan
c7ac0e4414 chore(docker): update Alpine base image to version 3.20 and bump XX_VERSION to 1.9.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-15 14:10:34 -05:00
Deluan
c9409d306a chore(deps): update Go dependencies to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-15 13:09:06 -05:00
Deluan
ebbe62bbbd fix(ui): update delete button color in AMusic theme
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-14 13:51:01 -05:00
dragonish
42c85a18e2 fix(ui) Improve player buttons in AMusic theme (#4797)
* fix(ui): improve the lyric button of the AMusic theme

* fix(amusic): update styles for music player panel SVG and disabled button states

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-13 13:04:29 -05:00
Deluan
7ccf44b8ed feat: rename HTTPSecurityHeaders.CustomFrameOptionsValue to HTTPHeaders.FrameOptions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-13 12:38:43 -05:00
Deluan
603cccde11 fix(subsonic): always enable getNowPlaying endpoint regardless of configuration
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-11 15:44:21 -05:00
Deluan
6ed6524752 fix(subsonic): add username parameter validation for GetUser endpoint
Fixes #4794

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 18:30:26 -05:00
Deluan
a081569ed4 fix(deezer): add order parameter to artist search for improved ranking
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 13:31:24 -05:00
Deluan
e923c02c6a chore: enhance Deezer logging for artist search results
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-10 08:38:28 -05:00
Deluan
51ca2dee65 fix: log environment variable configuration loading when no config file is found
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-09 19:40:46 -05:00
Deluan
6b961bd99d fix: update default legacy clients to include SubMusic. See #4779
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-09 08:44:56 -05:00
Deluan
396eee48c6 fix: preserve user context in async NowPlaying dispatch
Fixed issue #4787 where plugin scrobblers received an empty username during NowPlaying events. The async worker was passing context.Background() which lost all user information.

Changed nowPlayingEntry to store the full context (with cancellation removed via context.WithoutCancel) and pass it to dispatchNowPlaying. This ensures plugin scrobblers can extract username from the context for authorization checks.

Updated tests to verify username is properly propagated through the async workflow, matching the actual plugin adapter behavior of checking both request.UsernameFrom and request.UserFrom.
2025-12-09 08:43:56 -05:00
Deluan Quintão
cc3cca6077 fix(scanner): handle cross-library relative paths in playlists (#4659)
* fix: handle cross-library relative paths in playlists

Playlists can now reference songs in other libraries using relative paths.
Previously, relative paths like '../Songs/abc.mp3' would not resolve correctly
when pointing to files in a different library than the playlist file.

The fix resolves relative paths to absolute paths first, then checks which
library they belong to using the library regex. This allows playlists to
reference files across library boundaries while maintaining backward
compatibility with existing single-library relative paths.

Fixes #4617

* fix: enhance playlist path normalization for cross-library support

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

* refactor: improve handling of relative paths in playlists for cross-library compatibility

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

* fix: ensure longest library path matches first to resolve prefix conflicts in playlists

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

* test: refactor tests isolation

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

* fix: enhance handling of library-qualified paths and improve cross-library playlist support

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

* refactor: simplify mocks

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

* fix: lint

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

* fix: improve path resolution for cross-library playlists and enhance error handling

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

* refactor

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

* refactor: remove unnecessary path validation fallback

Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.

This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.

* refactor: consolidate path resolution logic

Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.

This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.

Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.

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

* docs: add FindByPaths comment

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

* fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-06 12:05:38 -05:00
Deluan Quintão
f6ac99e081 fix(ui): update Bulgarian, Finnish translations from POEditor (#4773)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-06 11:08:24 -05:00
Deluan Quintão
a521c74a59 feat(server): track scrobble/linstens history (#4770)
* feat(scrobble): implement scrobble repository and record scrobble history

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

* feat(scrobble): add configuration option to enable scrobble history

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

* test(scrobble): enhance scrobble history tests for repository recording

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-06 11:07:18 -05:00
Deluan Quintão
bfd219e708 fix(ui): update Esperanto, Finnish, Galician, Dutch, Norwegian, Turkish translations from POEditor (#4760)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-05 19:36:06 -05:00
Kendall Garner
eaf7795716 feat(cli): add user administration (#4754)
* feat(cli): add user administration

* clean go.mod, address comments

* fix lint, I hope

* bump compilation timeoit in adapter_media_agent_test

* address initial comments

* feedback 2

* update user commands to use context to allow proper cancellation

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

* enforce admin user requirement in context for command execution

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-03 19:58:33 -05:00
Deluan Quintão
96392f3af0 ci: improve docker manifest push reliability and isolation (#4764)
* ci: improve docker manifest push reliability and isolation

Split Docker manifest push into separate GHCR and Docker Hub jobs to improve pipeline reliability and resilience:

- Separated push-manifest job into push-manifest-ghcr and push-manifest-dockerhub for independent execution
- Filter tags per registry using jq to prevent cross-registry push attempts
- Add automatic retry logic (3 attempts with 30s delay) for Docker Hub push using nick-fields/retry action
- Make Docker Hub job continue-on-error to prevent Docker Hub intermittent failures from failing the entire pipeline
- Add dedicated cleanup-digests job that only requires GHCR job success
- GHCR is now the critical path and will fail the pipeline if it fails, while Docker Hub failures are tolerated with retries

This addresses the recurring 400 Bad Request errors from Docker Hub registry that were causing pipeline failures even when ghcr.io push succeeded.

* fix(ci): use ghcr.io as source for docker hub manifest creation

The docker buildx imagetools create command needs to reference the source images from where they exist (ghcr.io) rather than from Docker Hub. The digests uploaded during the build step are stored on ghcr.io, so we need to pull from there and tag to Docker Hub.

* fix(ci): simplify Docker manifest push job names for clarity

* fix(ci): add permissions for Docker manifest push jobs

* fix(ci): update permissions for GHCR manifest push to write

* fix(ci): update Docker Hub image tagging in manifest creation

* fix(ci): update permissions for GHCR manifest push to read contents and write packages

* Revert "fix(ci): update Docker Hub image tagging in manifest creation"

This reverts commit b5f04d9c8b.
2025-12-03 18:24:11 -05:00
maya doshi
b7c4128b1b fix(server): Lastfm.ScrobbleFirstArtistOnly also only scrobbles the first artist of the album (#4762)
* feat(server): add option Lastfm.ScrobbleFirstAlbumArtistOnly to send only the first album artist

* fix: remove config parameter scrobbleFirstAlbumArtist

* test: add NowPlaying test for ScrobbleFirstArtistOnly

Add a test case for the NowPlaying function when ScrobbleFirstArtistOnly is enabled. This ensures that only the first artist from the Participants list is sent to Last.fm for both artist and album artist fields, matching the existing test coverage for the Scrobble function.

* refactor: consolidate getArtistForScrobble and getAlbumArtistForScrobble

Merge the separate getArtistForScrobble and getAlbumArtistForScrobble functions into a single parameterized function. This eliminates code duplication and makes the scrobble artist handling logic more maintainable. The function now accepts a role parameter and display name, allowing it to handle both artist and album artist extraction based on the ScrobbleFirstArtistOnly configuration.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-03 15:55:25 -05:00
Deluan
86f929499e fix(ui): improve playlist bulk action button contrast on dark themes
The bulk action buttons (Make Public, Make Private, Delete) on the playlists list were displaying with poor text contrast when using dark themes like AMusic. The buttons had pinkish text (theme's primary color) on a dark red background, making them difficult to read.

This fix applies the same styling pattern used for song bulk actions by adding a makeStyles hook that sets white text color for dark themes. This ensures proper contrast between the button text and background while maintaining correct styling on light themes.

Tested on AMusic (dark) and Light themes to verify contrast improvement and backward compatibility.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-03 14:37:52 -05:00
dependabot[bot]
5bc26de0e7 chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in /ui (#4715)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 20:45:08 -05:00
Deluan
1f1a174542 fix(insights): add Parallels Shared Folders filesystem type to fsTypeMap
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 17:00:13 -05:00
Deluan
9f0d3f3cf4 fix(ui): sync body background color with theme
Set document.body.style.backgroundColor to match the current theme's background
color whenever the theme changes. This fixes the white background that appeared
during pull-to-refresh gestures on mobile or overscroll on desktop, where the
browser reveals the area behind the app content.

The background color is determined by the theme's palette.background.default
value if defined, otherwise falls back to Material-UI defaults (#303030 for
dark themes, #fafafa for light themes).

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 16:14:32 -05:00
Deluan
142a3136d4 fix: log warning when no config file is found
Always log the configuration source at startup: shows an INFO message with the
config file path when found, or a WARN message explaining how to specify one
when not found. This helps users understand why CLI commands may fail when
run outside of systemd (where --configfile is typically specified).

Closes #4758
2025-12-02 14:24:15 -05:00
Deluan Quintão
13f6eb9a11 feat: make Unicode handling in external API calls configurable (#4277)
* feat: make Unicode handling in external API calls configurable

- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks

Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.

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

* address comments

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

* avoid calling str.Clean multiple times

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

* refactor: apply Unicode handling pattern to auxAlbum

Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.

Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 13:08:30 -05:00
crazygolem
917726c166 feat: rename "reverse proxy authentication" to "external authentication" (#4418)
* Rename external auth options

ReverseProxyWhitelist was regularly confusing users that enabled it for
non-authenticating reverse proxy setups.

The new option name makes it clear that it's related to authentication, not
just reverse proxies.

* small refactor

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

* add test

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2025-12-02 12:01:48 -05:00
Deluan Quintão
654607ea53 fix(ui): update Danish, German, Greek, Spanish, French, Japanese, Polish, Russian, Swedish, Thai, Ukrainian translations from POEditor (#4687)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-12-02 11:38:26 -05:00
Xabi
5c43025ce1 fix(ui): update Basque translation to include library related strings that were missing (#4670)
* Update eu.json

Added Library strings

* Update eu.json, now with missing comma

There was a comma missing.

* Update eu.json, typo

Fixes a typo.
2025-12-02 11:31:02 -05:00
ChekeredList71
ff5ebe1829 fix(ui): new Hungarian strings and updates (#4703)
added: "quickscan", "fullscan"
updated:
- "manageUsers": `access` translates to `hozzáférés` in this context, not `elérés` (~reachableness)
- "quickscan", "fullscan", "scantype": updated to match new strings
2025-12-02 11:27:12 -05:00
floatlesss
3ac2c6b6ed fix: upgrade TagLib in devcontainer (#4750)
* Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>

fix(vscodedevcontainer): fix-taglib-build-issues - #4749

* Apply Gemini suggested changes

Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>

* chore: install TagLib in devcontainer Dockerfile

Move TagLib installation from postCreateCommand script into the devcontainer Dockerfile to leverage Docker layer caching and simplify setup.\n\nChanges:\n- Install cross-taglib v2.1.1-1 directly in Dockerfile using TARGETARCH for multi-arch support (amd64/arm64).\n- Remove redundant libtag1-dev apt dependency; keep ffmpeg only.\n- Add CROSS_TAGLIB_VERSION as a build arg for consistency with CI/Makefile.\n- Remove postCreateCommand from devcontainer.json and delete install-taglib.sh script.\n\nWhy:\n- Avoid re-downloading TagLib on each container create; benefit from cached image layers.\n- Reduce redundancy and potential version mismatch between apt libtag and cross-taglib.\n- Keep devcontainer aligned with production build approach and CI settings.

---------

Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-12-02 08:39:36 -05:00
Deluan Quintão
0faf744e32 refactor: make NowPlaying dispatch asynchronous with worker pool (#4757)
* feat: make NowPlaying dispatch asynchronous with worker pool

Implemented asynchronous NowPlaying dispatch using a queue worker pattern similar to cacheWarmer. Instead of dispatching NowPlaying updates synchronously during the HTTP request, they are now queued and processed by background workers at controlled intervals.

Key changes:
- Added nowPlayingEntry struct to represent queued entries
- Added npQueue map (keyed by playerId), npMu mutex, and npSignal channel to playTracker
- Implemented enqueueNowPlaying() to add entries to the queue
- Implemented nowPlayingWorker() that polls every 100ms, drains queue, and processes entries
- Changed NowPlaying() to queue dispatch instead of calling synchronously
- Renamed dispatchNowPlaying() to dispatchNowPlayingAsync() and updated it to use background context

Benefits:
- HTTP handlers return immediately without waiting for scrobbler responses
- Deduplication by key: rapid calls (seeking) only dispatch latest state
- Fire-and-forget: one-shot attempts with logged failures
- Backpressure-free: worker processes at its own pace
- Tests updated to use Eventually() assertions for async dispatch

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

* fix(play_tracker): increase timeout duration for signal handling

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

* refactor(play_tracker): simplify queue processing by directly assigning entries

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-01 22:21:54 -05:00
Deluan Quintão
33d9ce6ecc feat: add configurable transcoding cancellation (#4411)
* feat: add configurable transcoding cancellation

Implemented EnableTranscodingCancellation configuration option to control whether
FFmpeg transcoding processes can be interrupted when client requests are cancelled.
This addresses resource management issues on low-power hardware where transcoding
processes would accumulate and cause CPU spikes.

Key changes:
- Added EnableTranscodingCancellation bool to configuration (default: false)
- Added CLI flag --enabletranscodingcancellation and TOML/env support
- Modified FFmpeg package to always use exec.CommandContext for consistency
- Implemented conditional context handling in NewTranscodingCache function
- When enabled: uses request context directly (allows cancellation)
- When disabled: uses background context with request metadata preserved
- Added comprehensive tests for both FFmpeg and transcoding layers
- Maintained backward compatibility with existing behavior as default

The implementation follows proper layered architecture with policy decisions
at the media streaming layer and execution utilities remaining focused on
their core responsibilities.

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

* test: refactor FFmpeg context cancellation tests for improved clarity and reliability

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

* test: reset FFmpeg initialization

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

* test: improve FFmpeg context cancellation tests for cross-platform compatibility

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-01 17:33:53 -05:00
Deluan
f14692c1f0 test: remove racy buffer length assertion in scrobbler test
Removed the buffer.Length() check that was causing intermittent test failures.
The background goroutine started by newBufferedScrobbler can process and
dequeue scrobble entries before the test assertion runs, leading to a race
condition where the observed length is 0 instead of 1. The Eventually block
that follows already verifies the scrobble was processed correctly.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-30 21:59:11 -05:00
Deluan
75b253687a fix(insights): add missing filesystem types to fsTypeMap 2025-11-30 11:26:59 -05:00
97 changed files with 4674 additions and 1183 deletions

View File

@@ -9,12 +9,19 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] Uncomment this section to install additional OS packages.
# Install additional OS packages
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
&& apt-get -y install --no-install-recommends ffmpeg
# [Optional] Uncomment the next line to use go get to install anything else you need
# RUN go get -x <your-dependency-or-tool>
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
&& mv /usr/include/taglib/* /usr/include/ \
&& rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
# [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

View File

@@ -7,7 +7,8 @@
"VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
}
},
"workspaceMount": "",
@@ -54,12 +55,10 @@
4533,
4633
],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
}
}

View File

@@ -217,7 +217,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -248,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -256,8 +256,11 @@ jobs:
if-no-files-found: error
retention-days: 1
push-manifest:
name: Push Docker manifest
push-manifest-ghcr:
name: Push to GHCR
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
@@ -267,7 +270,41 @@ jobs:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub:
name: Push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -282,28 +319,27 @@ jobs:
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 30
command: |
cd /tmp/digests
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
cleanup-digests:
name: Cleanup digest artifacts
runs-on: ubuntu-latest
needs: [push-manifest-ghcr, push-manifest-dockerhub]
if: always() && needs.push-manifest-ghcr.result == 'success'
steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
@@ -320,7 +356,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-windows*
@@ -339,7 +375,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -357,7 +393,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
path: ./binaries
pattern: navidrome-*
@@ -383,7 +419,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: packages
path: dist/navidrome_0*
@@ -406,13 +442,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}

View File

@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
issue-inactive-days: 120

View File

@@ -24,7 +24,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot <navidrome-bot@navidrome.org>"

3
.gitignore vendored
View File

@@ -31,4 +31,5 @@ AGENTS.md
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
*.wasm
openspec/

View File

@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
@@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
@@ -122,7 +122,7 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"

View File

@@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
GOLANGCI_LINT_VERSION ?= v2.6.2
GOLANGCI_LINT_VERSION ?= v2.7.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@@ -10,11 +10,8 @@ import (
"strconv"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra"
)
@@ -52,7 +49,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
runExporter(cmd.Context())
},
}
@@ -60,15 +57,13 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
runList()
runList(cmd.Context())
},
}
)
func runExporter() {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
@@ -100,31 +95,19 @@ func runExporter() {
}
}
func runList() {
func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
ds, ctx := getAdminContext(ctx)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving user by name", "name", userID, err)
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}

View File

@@ -374,6 +374,7 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@@ -397,6 +398,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}

View File

@@ -1,9 +1,12 @@
package cmd
import (
"bufio"
"context"
"encoding/gob"
"fmt"
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
@@ -19,12 +22,14 @@ var (
fullScan bool
subprocess bool
targets []string
targetFile string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
rootCmd.AddCommand(scanCmd)
}
@@ -71,10 +76,17 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
// Parse targets if provided
// Parse targets from command line or file
var scanTargets []model.ScanTarget
if len(targets) > 0 {
var err error
var err error
if targetFile != "" {
scanTargets, err = readTargetsFromFile(targetFile)
if err != nil {
log.Fatal(ctx, "Failed to read targets from file", err)
}
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
} else if len(targets) > 0 {
scanTargets, err = model.ParseTargets(targets)
if err != nil {
log.Fatal(ctx, "Failed to parse targets", err)
@@ -94,3 +106,31 @@ func runScanner(ctx context.Context) {
trackScanInteractively(ctx, progress)
}
}
// readTargetsFromFile reads scan targets from a file, one per line.
// Each line should be in the format "libraryID:folderPath".
// Empty lines and lines starting with # are ignored.
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open target file: %w", err)
}
defer file.Close()
var targetStrings []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" {
continue
}
targetStrings = append(targetStrings, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read target file: %w", err)
}
return model.ParseTargets(targetStrings)
}

89
cmd/scan_test.go Normal file
View File

@@ -0,0 +1,89 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("readTargetsFromFile", func() {
var tempDir string
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "navidrome-test-")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
It("reads valid targets from file", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(3))
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
})
It("skips empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
It("trims whitespace", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
})
It("returns error for non-existent file", func() {
_, err := readTargetsFromFile("/nonexistent/file.txt")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
})
It("returns error for invalid target format", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "invalid-format\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
_, err = readTargetsFromFile(filePath)
Expect(err).To(HaveOccurred())
})
It("handles mixed valid and empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
})

477
cmd/user.go Normal file
View File

@@ -0,0 +1,477 @@
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
email string
libraryIds []int
name string
removeEmail bool
removeName bool
setAdmin bool
setPassword bool
setRegularUser bool
)
func init() {
rootCmd.AddCommand(userRoot)
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
_ = userCreateCommand.MarkFlagRequired("username")
userRoot.AddCommand(userCreateCommand)
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
_ = userDeleteCommand.MarkFlagRequired("user")
userRoot.AddCommand(userDeleteCommand)
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
_ = userEditCommand.MarkFlagRequired("user")
userRoot.AddCommand(userEditCommand)
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
userRoot.AddCommand(userListCommand)
}
var (
userRoot = &cobra.Command{
Use: "user",
Short: "Administer users",
Long: "Create, delete, list, or update users",
}
userCreateCommand = &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a new user",
Run: func(cmd *cobra.Command, args []string) {
runCreateUser(cmd.Context())
},
}
userDeleteCommand = &cobra.Command{
Use: "delete",
Aliases: []string{"d"},
Short: "Deletes an existing user",
Run: func(cmd *cobra.Command, args []string) {
runDeleteUser(cmd.Context())
},
}
userEditCommand = &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit a user",
Long: "Edit the password, admin status, and/or library access",
Run: func(cmd *cobra.Command, args []string) {
runUserEdit(cmd.Context())
},
}
userListCommand = &cobra.Command{
Use: "list",
Short: "List users",
Run: func(cmd *cobra.Command, args []string) {
runUserList(cmd.Context())
},
}
)
func promptPassword() string {
for {
fmt.Print("Enter new password (press enter with no password to cancel): ")
// This cast is necessary for some platforms
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password", err)
}
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password confirmation", err)
}
// clear the line.
fmt.Println()
pass := string(password)
confirm := string(confirmation)
if pass == "" {
return ""
}
if pass == confirm {
return pass
}
fmt.Println("Password and password confirmation do not match")
}
}
func libraryError(libraries model.Libraries) error {
ids := make([]int, len(libraries))
for idx, library := range libraries {
ids[idx] = library.ID
}
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
}
func runCreateUser(ctx context.Context) {
password := promptPassword()
if password == "" {
log.Fatal("Empty password provided, user creation cancelled")
}
user := model.User{
UserName: userID,
Email: email,
Name: name,
IsAdmin: setAdmin,
NewPassword: password,
}
if user.Name == "" {
user.Name = userID
}
ds, ctx := getAdminContext(ctx)
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User(ctx).FindByUsername(userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("failed to check existing username: %w", err)
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(user.Libraries) != len(libraryIds) {
return libraryError(user.Libraries)
}
} else {
user.Libraries, err = tx.Library(ctx).GetAll()
if err != nil {
return err
}
}
err = tx.User(ctx).Put(&user)
if err != nil {
return err
}
updatedIds := make([]int, len(user.Libraries))
for idx, lib := range user.Libraries {
updatedIds[idx] = lib.ID
}
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
return err
})
if err != nil {
log.Fatal(ctx, err)
}
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
}
func runDeleteUser(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User(ctx).CountAll()
if err != nil {
return err
}
if count == 1 {
return errors.New("refusing to delete the last user")
}
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
return tx.User(ctx).Delete(user.ID)
})
if err != nil {
log.Fatal(ctx, "Failed to delete user", err)
}
log.Info(ctx, "Deleted user", "username", user.UserName)
}
func runUserEdit(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
changes := []string{}
err = ds.WithTx(func(tx model.DataStore) error {
var newLibraries model.Libraries
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(libraries) != len(libraryIds) {
return libraryError(libraries)
}
newLibraries = libraries
changes = append(changes, "updated library ids")
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library(ctx).GetAll()
if err != nil {
return err
}
user.IsAdmin = true
user.Libraries = libraries
changes = append(changes, "set admin")
newLibraries = libraries
}
if setRegularUser && user.IsAdmin {
user.IsAdmin = false
changes = append(changes, "set regular user")
}
if setPassword {
password := promptPassword()
if password != "" {
user.NewPassword = password
changes = append(changes, "updated password")
}
}
if email != "" && email != user.Email {
user.Email = email
changes = append(changes, "updated email")
} else if removeEmail && user.Email != "" {
user.Email = ""
changes = append(changes, "removed email")
}
if name != "" && name != user.Name {
user.Name = name
changes = append(changes, "updated name")
} else if removeName && user.Name != "" {
user.Name = ""
changes = append(changes, "removed name")
}
if len(changes) == 0 {
return nil
}
err := tx.User(ctx).Put(user)
if err != nil {
return err
}
if len(newLibraries) > 0 {
updatedIds := make([]int, len(newLibraries))
for idx, lib := range newLibraries {
updatedIds[idx] = lib.ID
}
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Fatal(ctx, "Failed to update user", err)
}
if len(changes) == 0 {
log.Info(ctx, "No changes for user", "user", user.UserName)
} else {
log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
}
}
type displayLibrary struct {
ID int `json:"id"`
Path string `json:"path"`
}
type displayUser struct {
Id string `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Admin bool `json:"admin"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastAccess *time.Time `json:"lastAccess"`
LastLogin *time.Time `json:"lastLogin"`
Libraries []displayLibrary `json:"libraries"`
}
func runUserList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
users, err := ds.User(ctx).ReadAll()
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{
"user id",
"username",
"user's name",
"user email",
"admin",
"created at",
"updated at",
"last access",
"last login",
"libraries",
})
for _, user := range userList {
paths := make([]string, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
}
var lastAccess, lastLogin string
if user.LastAccessAt != nil {
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
} else {
lastAccess = "never"
}
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
} else {
lastLogin = "never"
}
_ = w.Write([]string{
user.ID,
user.UserName,
user.Name,
user.Email,
strconv.FormatBool(user.IsAdmin),
user.CreatedAt.Format(time.RFC3339Nano),
user.UpdatedAt.Format(time.RFC3339Nano),
lastAccess,
lastLogin,
fmt.Sprintf("'%s'", strings.Join(paths, "|")),
})
}
w.Flush()
} else {
users := make([]displayUser, len(userList))
for idx, user := range userList {
paths := make([]displayLibrary, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx].ID = library.ID
paths[idx].Path = library.Path
}
users[idx].Id = user.ID
users[idx].Username = user.UserName
users[idx].Name = user.Name
users[idx].Email = user.Email
users[idx].Admin = user.IsAdmin
users[idx].CreatedAt = user.CreatedAt
users[idx].UpdatedAt = user.UpdatedAt
users[idx].LastAccess = user.LastAccessAt
users[idx].LastLogin = user.LastLoginAt
users[idx].Libraries = paths
}
j, _ := json.Marshal(users)
fmt.Printf("%s\n", j)
}
}

42
cmd/utils.go Normal file
View File

@@ -0,0 +1,42 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
)
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx = auth.WithAdminUser(ctx, ds)
u, _ := request.UserFrom(ctx)
if !u.IsAdmin {
log.Fatal(ctx, "There must be at least one admin user to run this command.")
}
return ds, ctx
}
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
user, err := ds.User(ctx).FindByUsername(id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("finding user by name: %w", err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("finding user by id: %w", err)
}
}
return user, nil
}

View File

@@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@@ -86,11 +87,10 @@ type configOptions struct {
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
ExtAuth extAuthOptions
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
HTTPHeaders httpHeaderOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
@@ -102,36 +102,38 @@ type configOptions struct {
Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Tags map[string]TagConf `json:",omitempty"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
}
type scannerOptions struct {
@@ -186,8 +188,8 @@ type listenBrainzOptions struct {
BaseURL string
}
type secureOptions struct {
CustomFrameOptionsValue string
type httpHeaderOptions struct {
FrameOptions string
}
type prometheusOptions struct {
@@ -229,6 +231,11 @@ type pluginsOptions struct {
CacheSize string
}
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var (
Server = &configOptions{}
hooks []func()
@@ -247,6 +254,11 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -330,9 +342,18 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
// Log configuration source
if Server.ConfigFile != "" {
log.Info("Loaded configuration", "file", Server.ConfigFile)
} else if hasNDEnvVars() {
log.Info("No configuration file found. Loaded configuration only from environment variables")
} else {
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
}
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@@ -347,9 +368,12 @@ func Load(noConfigDump bool) {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
// Call init hooks
for _, hook := range hooks {
@@ -357,16 +381,30 @@ func Load(noConfigDump bool) {
}
}
func logDeprecatedOptions(options ...string) {
for _, option := range options {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
if os.Getenv(envVar) != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
}
if viper.InConfig(option) {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
func logDeprecatedOptions(oldName, newName string) {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
logWarning := func(oldName, newName string) {
if newName != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
} else {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
}
}
if os.Getenv(envVar) != "" {
logWarning(envVar, newEnvVar)
}
if viper.InConfig(oldName) {
logWarning(oldName, newName)
}
}
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOption(legacyName, newName string) {
if viper.IsSet(legacyName) {
viper.Set(newName, viper.Get(legacyName))
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
@@ -475,6 +513,16 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
func hasNDEnvVars() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
return true
}
}
return false
}
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
@@ -492,6 +540,7 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -536,8 +585,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -558,7 +607,7 @@ func setViperDefaults() {
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
@@ -571,7 +620,8 @@ func setViperDefaults() {
viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -611,6 +661,7 @@ func setViperDefaults() {
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
}
func init() {

View File

@@ -41,6 +41,9 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// Check deprecated option mapping
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
},

View File

@@ -1,6 +1,7 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
ReverseProxyUserHeader = 'X-Auth-User'
[Tags]
Custom.Aliases = ini,test

View File

@@ -1,6 +1,7 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"reverseProxyUserHeader": "X-Auth-User",
"Tags": {
"artist": {
"split": ";"

View File

@@ -1,5 +1,6 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
ReverseProxyUserHeader = "X-Auth-User"
Tags.artist.Split = ';'

View File

@@ -1,5 +1,6 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
reverseProxyUserHeader: "X-Auth-User"
Tags:
artist:
split: [";"]

View File

@@ -43,6 +43,7 @@ func newClient(hc httpDoer, language string) *client {
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("order", "RANKING")
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {

View File

@@ -3,6 +3,7 @@ package deezer
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -82,10 +83,20 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return nil, err
}
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
for i := range artists {
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
if i > 2 {
break
}
}
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
}

View File

@@ -290,11 +290,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
return track.Participants[model.RoleArtist][0].Name
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name
}
return track.Artist
return displayName
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -304,13 +304,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track),
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -330,13 +330,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile),
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {

View File

@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
model.RoleAlbumArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
},
},
}
})
@@ -229,6 +233,23 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
})
Describe("scrobble", func() {
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})

View File

@@ -51,12 +51,28 @@ type provider struct {
type auxAlbum struct {
model.Album
Name string
}
// Name returns the appropriate album name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxAlbum) Name() string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Album.Name
}
return str.Clear(a.Album.Name)
}
type auxArtist struct {
model.Artist
Name string
}
// Name returns the appropriate artist name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxArtist) Name() string {
if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Artist.Name
}
return str.Clear(a.Artist.Name)
}
type Agents interface {
@@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
albumName := album.Name()
if updatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -116,7 +132,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album)
}
@@ -125,12 +141,13 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
albumName := album.Name()
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
@@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description
}
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
@@ -161,7 +178,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil {
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
@@ -181,7 +198,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -210,8 +226,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
artistName := artist.Name()
if updatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -220,7 +237,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -229,8 +246,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
artistName := artist.Name()
if artist.MbzArtistID == "" {
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
@@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = g.Wait()
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
@@ -281,7 +299,7 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
}
topCount := max(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
@@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
albumName := album.Name()
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumImages call canceled", err)
default:
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
}
return nil, err
}
if len(images) == 0 {
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
@@ -401,9 +420,10 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
artistName := artist.Name()
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
if err != nil {
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
@@ -415,13 +435,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
log.Debug(ctx, "No matching top songs found", "name", artist.Name)
log.Debug(ctx, "No matching top songs found", "name", artistName)
} else {
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
}
return mfs, nil
@@ -518,7 +538,7 @@ func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[strin
}
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 {
return
}
@@ -526,7 +546,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -536,7 +556,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
}
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -555,13 +575,14 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
artistName := artist.Name()
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
}
@@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 {
return nil, model.ErrNotFound
}
artist := &auxArtist{
Artist: artists[0],
Name: str.Clear(artists[0].Name),
}
return artist, nil
return &auxArtist{Artist: artists[0]}, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -655,7 +672,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
return err
}

View File

@@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
Context("Unicode handling in album names", func() {
var albumWithEnDash *model.Album
var expectedURL *url.URL
const (
originalAlbumName = "Raising HellDeluxe" // Album name with en dash
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
)
BeforeEach(func() {
// Test with en dash () in album name
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
expectedURL, _ = url.Parse("http://example.com/album.jpg")
// Mock the album agent to return an image for the album
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/album.jpg", Size: 1000},
}, nil).Once()
})
When("DevPreserveUnicodeInExternalCalls is true", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = true
})
It("preserves Unicode characters in album names", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This is the key assertion: ensure the original Unicode name is used
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
})
})
When("DevPreserveUnicodeInExternalCalls is false", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
})
It("normalizes Unicode characters", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This assertion ensures the normalized name is used (en dash → hyphen)
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
})
})
})
})
// mockAlbumInfoAgent implementation

View File

@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
Context("Unicode handling in artist names", func() {
var artistWithEnDash *model.Artist
var expectedURL *url.URL
const (
originalArtistName = "RunD.M.C." // Artist name with en dash
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
)
BeforeEach(func() {
// Test with en dash () in artist name like "RunD.M.C."
artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
// Mock the image agent to return an image for the artist
mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
Return([]agents.ExternalImage{
{URL: "http://example.com/rundmc.jpg", Size: 1000},
}, nil).Once()
})
When("DevPreserveUnicodeInExternalCalls is true", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = true
})
It("preserves Unicode characters in artist names", func() {
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
// This is the key assertion: ensure the original Unicode name is used
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
})
})
When("DevPreserveUnicodeInExternalCalls is false", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
})
It("normalizes Unicode characters", func() {
// Act
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
// This assertion ensures the normalized name is used (en dash → hyphen)
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
})
})
})
})
// mockArtistImageAgent implementation using testify/mock

View File

@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start()
err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd
}
func (j *ffCmd) start() error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
func (j *ffCmd) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr

View File

@@ -1,7 +1,11 @@
package ffmpeg
import (
"context"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
BeforeEach(func() {
ffOnce = sync.Once{}
ff = New()
// Skip if FFmpeg is not available
if !ff.IsAvailable() {
Skip("FFmpeg not available on this system")
}
})
It("should interrupt transcoding when context is cancelled", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Use a command that generates audio indefinitely
// -f lavfi uses FFmpeg's built-in audio source
// -t 0 means no time limit (runs forever)
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
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Read some data first to ensure FFmpeg is running
buf := make([]byte, 1024)
_, err = stream.Read(buf)
Expect(err).ToNot(HaveOccurred())
// Cancel the context
cancel()
// Next read should fail due to cancelled context
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
It("should handle immediate context cancellation", func() {
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
Expect(err).To(MatchError(context.Canceled))
})
})
Context("with mock process behavior", func() {
var longRunningCmd string
BeforeEach(func() {
// Use a long-running command for testing cancellation
switch runtime.GOOS {
case "windows":
// Use PowerShell's Start-Sleep
ffmpegPath = "powershell"
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
default:
// Use sleep on Unix-like systems
ffmpegPath = "sleep"
longRunningCmd = "sleep 10"
}
})
It("should terminate the underlying process when context is cancelled", func() {
ff := New()
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Give the process time to start
time.Sleep(50 * time.Millisecond)
// Cancel the context
cancel()
// Try to read from the stream, which should fail
buf := make([]byte, 100)
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
// Verify the stream is closed by attempting another read
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
})
})
})

View File

@@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
// This is where we decide whether transcoding processes should be cancellable or not.
var transcodingCtx context.Context
if conf.Server.EnableTranscodingCancellation {
// Use the request context directly, allowing cancellation when client disconnects
transcodingCtx = ctx
} else {
// Use background context with request values preserved.
// This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@@ -223,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0

View File

@@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
0x7c7c6673: "prlfs", // Parallels Shared Folders
// Signed/unsigned conversion issues (negative hex values converted to uint32)
-0x6edc97c2: "btrfs", // 0x9123683e
-0x1acb2be: "smb2", // 0xfe534d42
-0xacb2be: "cifs", // 0xff534d42
-0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {

View File

@@ -1,6 +1,7 @@
package core
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@@ -194,22 +195,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
found, err := mediaFileRepository.FindByPaths(paths)
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
// See https://github.com/navidrome/navidrome/issues/4663
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
return strings.ToLower(norm.NFD.String(path))
})
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
// Build lookup map with library-qualified keys, normalized for comparison
existing := make(map[string]int, len(found))
for idx := range found {
existing[normalizePathForComparison(found[idx].Path)] = idx
// Normalize to lowercase for case-insensitive comparison
// Key format: "libraryID:path"
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
existing[key] = idx
}
for _, path := range paths {
idx, ok := existing[normalizePathForComparison(path)]
// Find media files in the order of the resolved paths, to keep playlist order
for _, path := range resolvedPaths {
idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -226,69 +240,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
// for consistent comparison. This fixes Unicode normalization issues on macOS where
// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
func normalizePathForComparison(path string) string {
return strings.ToLower(norm.NFC.String(path))
// pathResolution holds the result of resolving a playlist path to a library-relative path.
type pathResolution struct {
absolutePath string
libraryPath string
libraryID int
valid bool
}
// TODO This won't work for multiple libraries
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
libRegex, err := s.compileLibraryPaths(ctx)
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
// Format: "libraryID:relativePath" with forward slashes for path separators.
func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid {
return "", fmt.Errorf("invalid path resolution")
}
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
return nil, err
return "", err
}
res := make([]string, 0, len(lines))
for idx, line := range lines {
var libPath string
var filePath string
if folder != nil && !filepath.IsAbs(line) {
libPath = folder.LibraryPath
filePath = filepath.Join(folder.AbsolutePath(), line)
} else {
cleanLine := filepath.Clean(line)
if libPath = libRegex.FindString(cleanLine); libPath != "" {
filePath = cleanLine
}
}
if libPath != "" {
if rel, err := filepath.Rel(libPath, filePath); err == nil {
res = append(res, rel)
} else {
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
"filePath", filePath, err)
}
} else {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
}
}
return slice.Map(res, filepath.ToSlash), nil
// Convert path separators to forward slashes
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// Create regex patterns for each library path
patterns := make([]string, len(libs))
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
}
}
}
return 0, ""
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
// This ensures correct matching when library paths are prefixes of each other.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
// Sort libraries by path length (descending) to ensure longest paths match first.
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Pre-clean all library paths once for efficient matching
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanPath := filepath.Clean(lib.Path)
escapedPath := regexp.QuoteMeta(cleanPath)
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
cleanedPaths[i] = filepath.Clean(lib.Path)
}
// Combine all patterns into a single regex
combinedPattern := strings.Join(patterns, "|")
re, err := regexp.Compile(combinedPattern)
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports.
type pathResolver struct {
matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
return nil, err
}
return re, nil
matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
// resolvePath determines the absolute path and library path for a playlist entry.
// For absolute paths, it uses them directly.
// For relative paths, it resolves them relative to the playlist's folder location.
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
//
// resolves to /music/songs/abc.mp3
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
var absolutePath string
if folder != nil && !filepath.IsAbs(line) {
// Resolve relative path to absolute path based on playlist location
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
} else {
// Use absolute path directly after cleaning
absolutePath = filepath.Clean(line)
}
return r.findInLibraries(absolutePath)
}
// findInLibraries matches an absolute path against all known libraries and returns
// a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{
absolutePath: absolutePath,
libraryPath: libPath,
libraryID: libID,
valid: true,
}
}
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return nil, err
}
results := make([]string, 0, len(lines))
for idx, line := range lines {
resolution := resolver.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
continue
}
qualifiedPath, err := resolution.ToQualifiedString()
if err != nil {
log.Debug(ctx, "Error getting library-qualified path", "path", line,
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
continue
}
results = append(results, qualifiedPath)
}
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {

View File

@@ -0,0 +1,406 @@
package core
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
})
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}
Describe("Longest library path matching", func() {
It("matches the longest library path when multiple libraries share a prefix", func() {
// Setup libraries with prefix conflicts
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/music-classical/opera"},
})
matcher := createMatcher(ds)
// Test that longest path matches first and returns correct library ID
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
{"/music-classical/track.mp3", 2, "/music-classical"},
{"/music/track.mp3", 1, "/music"},
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
}
})
It("handles libraries with similar prefixes but different structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/home/user/music"},
{ID: 2, Path: "/home/user/music-backup"},
})
matcher := createMatcher(ds)
// Test that music-backup library is matched correctly
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/home/user/music"))
})
It("matches path that is exactly the library root", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
})
matcher := createMatcher(ds)
// Exact library path should match
libID, libPath := matcher.findLibraryForPath("/music-classical")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/music-classical"))
})
It("handles complex nested library structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/media"},
{ID: 2, Path: "/media/audio"},
{ID: 3, Path: "/media/audio/classical"},
{ID: 4, Path: "/media/audio/classical/baroque"},
})
matcher := createMatcher(ds)
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
{"/media/video/movie.mp4", 1, "/media"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
}
})
})
Describe("Edge cases", func() {
It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Should not match anything
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(0))
Expect(libPath).To(BeEmpty())
})
It("handles single library", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
})
matcher := createMatcher(ds)
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music"))
})
It("handles libraries with special characters in paths", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music[test]"},
{ID: 2, Path: "/music(backup)"},
})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Special characters should match literally
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music[test]"))
})
})
Describe("Path matching order", func() {
It("ensures longest paths match first", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/a"},
{ID: 2, Path: "/ab"},
{ID: 3, Path: "/abc"},
})
matcher := createMatcher(ds)
// Verify that longer paths match correctly (not cut off by shorter prefix)
testCases := []struct {
path string
expectedLibID int
}{
{"/abc/file.mp3", 3},
{"/ab/file.mp3", 2},
{"/a/file.mp3", 1},
}
for _, tc := range testCases {
libID, _ := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
}
})
})
})
var _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
var resolver *pathResolver
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
// Setup test libraries
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/podcasts"},
})
var err error
resolver, err = newPathResolver(ctx, ds)
Expect(err).ToNot(HaveOccurred())
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
})
It("resolves path to the longest matching library", func() {
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid resolution for path outside libraries", func() {
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
Expect(resolution.valid).To(BeFalse())
})
It("cleans the path before matching", func() {
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
})
})
Context("With relative paths", func() {
It("resolves relative path within same library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../songs/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
})
It("resolves relative path to different library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Path goes up and into a different library
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3))
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("uses matcher to find correct library for resolved path", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// This relative path resolves to music-classical library
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid for relative paths escaping all libraries", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
Expect(resolution.valid).To(BeFalse())
})
})
})
Describe("Cross-library resolution scenarios", func() {
It("handles playlist in library A referencing file in library B", func() {
// Playlist is in /music/playlists
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Relative path that goes to /podcasts library
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("prefers longer library paths when resolving", func() {
// Ensure /music-classical is matched instead of /music
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
})
})
})
var _ = Describe("pathResolution", func() {
Describe("ToQualifiedString", func() {
It("converts valid resolution to qualified string with forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 1,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
})
It("handles Windows-style paths by converting to forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 2,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
// Should always use forward slashes regardless of OS
Expect(qualifiedStr).To(ContainSubstring("2:"))
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
})
It("returns error for invalid resolution", func() {
resolution := pathResolution{valid: false}
_, err := resolution.ToQualifiedString()
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -1,4 +1,4 @@
package core
package core_test
import (
"context"
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps Playlists
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@@ -33,16 +34,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
@@ -112,6 +113,224 @@ var _ = Describe("Playlists", func() {
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
})
Describe("Cross-library relative paths", func() {
var tmpDir, plsDir, songsDir string
BeforeEach(func() {
// Create temp directory structure
tmpDir = GinkgoT().TempDir()
plsDir = tmpDir + "/playlists"
songsDir = tmpDir + "/songs"
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
// Setup two different libraries with paths matching our temp structure
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: songsDir},
{ID: 2, Path: plsDir},
})
// Create a mock media file repository that returns files for both libraries
// Note: The paths are relative to their respective library roots
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3", // This is songs/abc.mp3 relative to songsDir
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = core.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
// Create a temporary playlist file with relative path
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
// Playlist is in the Playlists library folder
// Important: Path should be relative to LibraryPath, and Name is the folder name
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
})
It("ignores paths that point outside all libraries", func() {
// Create a temporary playlist file with path outside libraries
plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
plsFile := plsDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should only find abc.mp3, not outside.mp3
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
})
It("handles relative paths with multiple '../' components", func() {
// Create a nested structure: tmpDir/playlists/subfolder/test.m3u
subFolder := plsDir + "/subfolder"
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
// Create the media file in the subfolder directory
// The mock will return it as "def.mp3" relative to plsDir
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"abc.mp3", // From songsDir library
"def.mp3", // From plsDir library root
},
}
// From subfolder, ../../songs/abc.mp3 should resolve to songs library
// ../def.mp3 should resolve to plsDir/def.mp3
plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
plsFile := subFolder + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
// The folder: AbsolutePath = LibraryPath + Path + Name
// So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
plsFolder := &model.Folder{
ID: "2",
LibraryID: 2,
LibraryPath: plsDir,
Path: "", // Empty because subfolder is directly under library root
Name: "subfolder", // The folder name
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
})
It("correctly resolves libraries when one path is a prefix of another", func() {
// This tests the bug where /music would match before /music-classical
// Create temp directory structure with prefix conflict
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
musicClassicalDir := tmpDir + "/music-classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
// Setup two libraries where one is a prefix of the other
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir}, // /tmp/xxx/music
{ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
})
// Mock will return tracks from both libraries
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
data: []string{
"rock.mp3", // From music library
"bach.mp3", // From music-classical library
},
}
// Create playlist in music library that references music-classical
plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
})
It("correctly handles identical relative paths from different libraries", func() {
// This tests the bug where two libraries have files at the same relative path
// and only one appears in the playlist
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
classicalDir := tmpDir + "/classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
// Create placeholder files so paths resolve correctly
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
// Both libraries have a file at "album/track.mp3"
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: classicalDir},
})
// Mock returns files with same relative path but different IDs and library IDs
// Keys use the library-qualified format: "libraryID:path"
ds.MockedMediaFile = &mockedMediaFileRepo{
data: map[string]model.MediaFile{
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
// Verify we got tracks from DIFFERENT libraries (the key fix!)
// Collect the library IDs
libIDs := make(map[int]bool)
for _, track := range pls.Tracks {
libIDs[track.LibraryID] = true
}
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
// Both tracks should have the same relative path
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
})
})
})
Describe("ImportM3U", func() {
@@ -119,7 +338,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -206,53 +425,23 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("handles Unicode normalization when comparing paths", func() {
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
nfdPath := norm.NFD.String(pathWithAccents)
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
repo.data = []string{nfdPath}
// Simulate an Apple Music M3U playlist entry with NFC encoding
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
m3u := strings.Join([]string{
nfcPath,
}, "\n")
// Simulate Apple Music M3U: uses NFC (composed) form
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
m3u := nfcPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
Expect(pls.Tracks).To(HaveLen(1))
// Should match despite different Unicode normalization forms
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
})
Describe("normalizePathForComparison", func() {
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
// Test with NFD (decomposed) input - as would come from macOS filesystem
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
normalized := normalizePathForComparison(nfdPath)
Expect(normalized).To(Equal("michèle"))
// Test with NFC (composed) input - as would come from Apple Music M3U
nfcPath := "Michèle" // This might be in NFC form
normalizedNfc := normalizePathForComparison(nfcPath)
// Ensure the two paths are not equal in their original forms
Expect(nfdPath).ToNot(Equal(nfcPath))
// Both should normalize to the same result
Expect(normalized).To(Equal(normalizedNfc))
})
It("handles paths with mixed case and Unicode characters", func() {
path := "Artist/Noël Coward/Album/Song.mp3"
normalized := normalizePathForComparison(path)
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
})
})
Describe("InPlaylistsPath", func() {
@@ -269,27 +458,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -297,22 +486,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
Expect(InPlaylistsPath(folder2)).To(BeTrue())
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct {
model.MediaFileRepository
data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
// If data map provided, look up files
if r.data != nil {
for _, path := range paths {
if mf, ok := r.data[path]; ok {
mfs = append(mfs, mf)
}
}
return mfs, nil
}
// Otherwise, create MediaFiles from paths
for idx, path := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := path
libraryID := 1
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
ID: strconv.Itoa(idx),
Path: actualPath,
LibraryID: libraryID,
})
}
return mfs, nil
@@ -324,13 +538,38 @@ type mockedMediaFileFromListRepo struct {
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
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 {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := requestPath
libraryID := 1
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
// The request path should already be normalized to NFD by production code
// before calling FindByPaths (to match DB storage)
normalizedRequestPath := norm.NFD.String(actualPath)
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: dataPath, // Return original path from DB
LibraryID: libraryID,
})
break
}
}
}
return mfs, nil
}

View File

@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
Expect(scr.NowPlayingCalled).To(BeTrue())
Expect(scr.UserID).To(Equal("user1"))
Expect(scr.Track).To(Equal(track))
Expect(scr.GetNowPlayingCalled()).To(BeTrue())
Expect(scr.GetUserID()).To(Equal("user1"))
Expect(scr.GetTrack()).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
Expect(buffer.Length()).To(Equal(int64(1)))
// Wait for the scrobble to be sent
// Wait for the background goroutine to process the scrobble.
// We don't check buffer.Length() here because the background goroutine
// may dequeue the entry before we can observe it.
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()

View File

@@ -31,6 +31,13 @@ type Submission struct {
Timestamp time.Time
}
type nowPlayingEntry struct {
ctx context.Context
userId string
track *model.MediaFile
position int
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@@ -52,6 +59,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
npQueue map[string]nowPlayingEntry
npMu sync.Mutex
npSignal chan struct{}
shutdown chan struct{}
workerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@@ -71,6 +83,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
npQueue: make(map[string]nowPlayingEntry),
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@@ -90,9 +106,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
return p
}
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() {
close(p.shutdown)
<-p.workerDone // Wait for worker to finish
}
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
@@ -198,11 +221,60 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf, position)
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
}
return nil
}
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
@@ -276,8 +348,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
if err != nil {
return err
}
}
return err
if conf.Server.EnableScrobbleHistory {
return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
}
return nil
})
}

View File

@@ -24,15 +24,26 @@ import (
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.names
}
func (m *mockPluginLoader) SetNames(names []string) {
m.mu.Lock()
defer m.mu.Unlock()
m.names = names
}
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
return s, ok
}
@@ -46,24 +57,24 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
var fake fakeScrobbler
var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = fakeScrobbler{Authorized: true}
fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
return &fake
return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker()
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.Track.ID).To(Equal("123"))
Expect(fake.Track.Participants).To(Equal(track.Participants))
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@@ -153,6 +170,17 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
It("passes user to scrobbler via context (fix for issue #4787)", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
// Verify the username was passed through async dispatch via context
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
})
Describe("GetNowPlaying", func() {
@@ -160,9 +188,9 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -210,7 +238,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@@ -274,49 +302,82 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
Context("Scrobble History", func() {
It("records scrobble in repository", func() {
conf.Server.EnableScrobbleHistory = true
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
ts := time.Now()
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
})
It("does not record scrobble when history is disabled", func() {
conf.Server.EnableScrobbleHistory = false
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
ts := time.Now()
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
mockDS := ds.(*tests.MockDataStore)
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
})
})
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake fakeScrobbler
var pluginFake *fakeScrobbler
BeforeEach(func() {
pluginFake = fakeScrobbler{Authorized: true}
pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = &fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
pluginFake.NowPlayingCalled = false
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.names = []string{}
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(pluginFake.NowPlayingCalled).To(BeFalse())
// Should not be called since plugin was removed
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.NowPlayingCalled = false
pluginFake.NowPlayingCalled = false
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -334,7 +395,7 @@ var _ = Describe("PlayTracker", func() {
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
ctx = context.Background()
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
@@ -359,7 +420,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
mockPlugin.names = []string{}
mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -375,32 +436,69 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
NowPlayingCalled bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
UserID string
Track *model.MediaFile
Position int
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
}
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
return f.nowPlayingCalled.Load()
}
func (f *fakeScrobbler) GetUserID() string {
if p := f.userID.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
f.NowPlayingCalled = true
f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
f.UserID = userId
f.Track = track
f.Position = position
f.userID.Store(&userId)
// Capture username from context (this is what plugin scrobblers do)
username, _ := request.UsernameFrom(ctx)
if username == "" {
if u, ok := request.UserFrom(ctx); ok {
username = u.UserName
}
}
if username != "" {
f.username.Store(&username)
}
f.track.Store(track)
f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.UserID = userId
f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {

View File

@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE scrobbles(
media_file_id VARCHAR(255) NOT NULL
REFERENCES media_file(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
user_id VARCHAR(255) NOT NULL
REFERENCES user(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
submission_time INTEGER NOT NULL
);
CREATE INDEX scrobbles_date ON scrobbles (submission_time);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE scrobbles;
-- +goose StatementEnd

35
go.mod
View File

@@ -39,13 +39,13 @@ require (
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/maruel/natural v1.2.1
github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.27.2
github.com/onsi/gomega v1.38.2
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.26.0
@@ -54,21 +54,21 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.10.1
github.com/tetratelabs/wazero v1.11.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/image v0.33.0
golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.31.0
golang.org/x/image v0.34.0
golang.org/x/net v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.39.0
golang.org/x/term v0.38.0
golang.org/x/text v0.32.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -90,7 +90,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -128,10 +128,11 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

70
go.sum
View File

@@ -99,8 +99,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -162,8 +162,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -186,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -244,8 +244,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -298,20 +298,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -323,8 +323,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -332,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -350,11 +350,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.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-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -363,6 +363,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -373,8 +375,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -384,12 +386,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -29,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
"(UserHeader:[\\s]*\")[^\"]*",
"(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",

View File

@@ -38,6 +38,7 @@ type DataStore interface {
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
Scrobble(ctx context.Context) ScrobbleRepository
Resource(ctx context.Context, model interface{}) ResourceRepository

13
model/scrobble.go Normal file
View File

@@ -0,0 +1,13 @@
package model
import "time"
type Scrobble struct {
MediaFileID string
UserID string
SubmissionTime time.Time
}
type ScrobbleRepository interface {
RecordScrobble(mediaFileID string, submissionTime time.Time) error
}

View File

@@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool {
type Users []User
type UserRepository interface {
ResourceRepository
CountAll(...QueryOptions) (int64, error)
Delete(id string) error
Get(id string) (*User, error)
Put(*User) error
UpdateLastLoginAt(id string) error

View File

@@ -512,6 +512,70 @@ var _ = Describe("AlbumRepository", func() {
// Clean up the test album created for this test
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
})
It("removes stale role associations when artist role changes", func() {
// Regression test for issue #4242: Composers displayed in albumartist list
// This happens when an artist's role changes (e.g., was both albumartist and composer,
// now only composer) and the old role association isn't properly removed.
// Create an artist that will have changing roles
artist := &model.Artist{
ID: "role-change-artist-1",
Name: "Role Change Artist",
OrderArtistName: "role change artist",
}
err := createArtistWithLibrary(artistRepo, artist, 1)
Expect(err).ToNot(HaveOccurred())
// Create album with artist as both albumartist and composer
album := &model.Album{
LibraryID: 1,
ID: "test-album-role-change",
Name: "Test Album Role Change",
AlbumArtistID: "role-change-artist-1",
AlbumArtist: "Role Change Artist",
Participants: model.Participants{
model.RoleAlbumArtist: {
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
},
model.RoleComposer: {
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
},
},
}
err = albumRepo.Put(album)
Expect(err).ToNot(HaveOccurred())
// Verify initial state: artist has both albumartist and composer roles
expected := []albumArtistRecord{
{ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
}
verifyAlbumArtists(album.ID, expected)
// Now update album so artist is ONLY a composer (remove albumartist role)
album.Participants = model.Participants{
model.RoleComposer: {
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
},
}
err = albumRepo.Put(album)
Expect(err).ToNot(HaveOccurred())
// Verify that the albumartist role was removed - only composer should remain
// This is the key test: before the fix, the albumartist role would remain
// causing composers to appear in the albumartist filter
expectedAfter := []albumArtistRecord{
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
}
verifyAlbumArtists(album.ID, expectedAfter)
// Clean up
_, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
})
})
})

View File

@@ -95,45 +95,82 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
}
func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
// If no specific paths, return all folders in the library
if len(targetPaths) == 0 {
return r.getFolderUpdateInfoAll(lib)
}
// Check if any path is root (return all folders)
for _, targetPath := range targetPaths {
if targetPath == "" || targetPath == "." {
return r.getFolderUpdateInfoAll(lib)
}
}
// Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
// Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
const batchSize = 100
result := make(map[string]model.FolderUpdateInfo)
for batch := range slices.Chunk(targetPaths, batchSize) {
batchResult, err := r.getFolderUpdateInfoBatch(lib, batch)
if err != nil {
return nil, err
}
for id, info := range batchResult {
result[id] = info
}
}
return result, nil
}
// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
where := And{
Eq{"library_id": lib.ID},
Eq{"missing": false},
}
return r.queryFolderUpdateInfo(where)
}
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
where := And{
Eq{"library_id": lib.ID},
Eq{"missing": false},
}
// If specific paths are requested, include those folders and all their descendants
if len(targetPaths) > 0 {
// Collect folder IDs for exact target folders and path conditions for descendants
folderIDs := make([]string, 0, len(targetPaths))
pathConditions := make(Or, 0, len(targetPaths)*2)
// Collect folder IDs for exact target folders and path conditions for descendants
folderIDs := make([]string, 0, len(targetPaths))
pathConditions := make(Or, 0, len(targetPaths)*2)
for _, targetPath := range targetPaths {
if targetPath == "" || targetPath == "." {
// Root path - include everything in this library
pathConditions = Or{}
folderIDs = nil
break
}
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
cleanPath = filepath.Clean(cleanPath)
for _, targetPath := range targetPaths {
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
cleanPath = filepath.Clean(cleanPath)
// Include the target folder itself by ID
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
// Include the target folder itself by ID
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
// Include all descendants: folders whose path field equals or starts with the target path
// Note: Folder.Path is the directory path, so children have path = targetPath
pathConditions = append(pathConditions, Eq{"path": cleanPath})
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
}
// Combine conditions: exact folder IDs OR descendant path patterns
if len(folderIDs) > 0 {
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
} else if len(pathConditions) > 0 {
where = append(where, pathConditions)
}
// Include all descendants: folders whose path field equals or starts with the target path
// Note: Folder.Path is the directory path, so children have path = targetPath
pathConditions = append(pathConditions, Eq{"path": cleanPath})
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
}
// Combine conditions: exact folder IDs OR descendant path patterns
if len(folderIDs) > 0 {
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
} else if len(pathConditions) > 0 {
where = append(where, pathConditions)
}
return r.queryFolderUpdateInfo(where)
}
// queryFolderUpdateInfo executes the query and returns the result map
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -193,12 +195,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, nil
}
// FindByPaths finds media files by their paths.
// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
// Library-qualified paths search within the specified library, while unqualified paths
// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
query := Or{}
for _, path := range paths {
parts := strings.SplitN(path, ":", 2)
if len(parts) == 2 {
// Library-qualified path: "libraryID:path"
libraryID, err := strconv.Atoi(parts[0])
if err != nil {
// Invalid format, skip
continue
}
relativePath := parts[1]
query = append(query, And{
Eq{"path collate nocase": relativePath},
Eq{"library_id": libraryID},
})
} else {
// Unqualified path: search across all libraries
query = append(query, Eq{"path collate nocase": path})
}
}
if len(query) == 0 {
return model.MediaFiles{}, nil
}
sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
return res.toModels(), nil
}

View File

@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
return NewScrobbleRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:

View File

@@ -0,0 +1,34 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type scrobbleRepository struct {
sqlRepository
}
func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
r := &scrobbleRepository{}
r.ctx = ctx
r.db = db
r.tableName = "scrobbles"
return r
}
func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
userID := loggedUser(r.ctx).ID
values := map[string]interface{}{
"media_file_id": mediaFileID,
"user_id": userID,
"submission_time": submissionTime.Unix(),
}
insert := Insert(r.tableName).SetMap(values)
_, err := r.executeSQL(insert)
return err
}

View File

@@ -0,0 +1,84 @@
package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("ScrobbleRepository", func() {
var repo model.ScrobbleRepository
var rawRepo sqlRepository
var ctx context.Context
var fileID string
var userID string
BeforeEach(func() {
fileID = id.NewRandom()
userID = id.NewRandom()
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
db := GetDBXBuilder()
repo = NewScrobbleRepository(ctx, db)
rawRepo = sqlRepository{
ctx: ctx,
tableName: "scrobbles",
db: db,
}
})
AfterEach(func() {
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
})
Describe("RecordScrobble", func() {
It("records a scrobble event", func() {
submissionTime := time.Now().UTC()
// Insert User
_, err := rawRepo.db.Insert("user", dbx.Params{
"id": userID,
"user_name": "user",
"password": "pw",
"created_at": time.Now(),
"updated_at": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
// Insert MediaFile
_, err = rawRepo.db.Insert("media_file", dbx.Params{
"id": fileID,
"path": "path",
"created_at": time.Now(),
"updated_at": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
err = repo.RecordScrobble(fileID, submissionTime)
Expect(err).ToNot(HaveOccurred())
// Verify insertion
var scrobble struct {
MediaFileID string `db:"media_file_id"`
UserID string `db:"user_id"`
SubmissionTime int64 `db:"submission_time"`
}
err = rawRepo.db.Select("*").From("scrobbles").
Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
One(&scrobble)
Expect(err).ToNot(HaveOccurred())
Expect(scrobble.MediaFileID).To(Equal(fileID))
Expect(scrobble.UserID).To(Equal(userID))
Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
})
})
})

View File

@@ -51,8 +51,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
}
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
ids := participants.AllIDs()
sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
// Delete all existing participant entries for this item.
// This ensures stale role associations are removed when an artist's role changes
// (e.g., an artist was both albumartist and composer, but is now only composer).
sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID})
_, err := r.executeSQL(sqd)
if err != nil {
return err

View File

@@ -3,6 +3,7 @@ package plugins
import (
"context"
"errors"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
// Ensure plugins folder is set to testdata
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins()

View File

File diff suppressed because it is too large Load Diff

View File

@@ -83,7 +83,7 @@
"actions": {
"playAll": "Afspil",
"playNext": "Afspil næste",
"addToQueue": "Afspil senere",
"addToQueue": "Føj til kø",
"shuffle": "Bland",
"addToPlaylist": "Føj til afspilningsliste",
"download": "Download",
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanningsbibliotek",
"manageUsers": "Administrer brugeradgang",
"viewDetails": "Se detaljer"
"viewDetails": "Se detaljer",
"quickScan": "hurtig skanning",
"fullScan": "Fuld skanning"
},
"notifications": {
"created": "Bibliotek oprettet",
"updated": "Biblioteket er blevet opdateret",
"deleted": "Biblioteket er blevet slettet",
"scanStarted": "Biblioteksscanning startet",
"scanCompleted": "Biblioteksscanning fuldført"
"scanCompleted": "Biblioteksscanning fuldført",
"quickScanStarted": "hurtig skanning startet",
"fullScanStarted": "Fuld skanning startet",
"scanError": "Kan ikke starte skanning. Tjek loggen"
},
"validation": {
"nameRequired": "Biblioteksnavn er påkrævet",
@@ -549,7 +554,7 @@
"closeText": "Luk",
"notContentText": "Ingen musik",
"clickToPlayText": "Tryk for at afspille",
"clickToPauseText": "Tryk for at pause",
"clickToPauseText": "Tryk for at sætte på pause",
"nextTrackText": "Næste nummer",
"previousTrackText": "Forrige nummer",
"reloadText": "Genindlæs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Type",
"status": "Scanningsfejl",
"elapsedTime": "Medgået tid"
"elapsedTime": "Medgået tid",
"selectiveScan": "Selektiv"
},
"help": {
"title": "Navidrome genvejstaster",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Bibliothek scannen",
"manageUsers": "Zugriff verwalten",
"viewDetails": "Details ansehen"
"viewDetails": "Details ansehen",
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan"
},
"notifications": {
"created": "Bibliothek erfolgreich erstellt",
"updated": "Bibliothek erfolgreich geändert",
"deleted": "Bibliothek erfolgreich gelöscht",
"scanStarted": "Bibliothek Scan gestartet",
"scanCompleted": "Bibliothek Scan vollständig"
"scanCompleted": "Bibliothek Scan vollständig",
"quickScanStarted": "Schneller Scan gestartet",
"fullScanStarted": "Kompletter Scan gestartet",
"scanError": "Fehler beim Starten des Scans. Logs prüfen"
},
"validation": {
"nameRequired": "Bibliotheksname ist Pflichtfeld",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
"elapsedTime": "Laufzeit"
"elapsedTime": "Laufzeit",
"selectiveScan": "Selektiver Scan"
},
"help": {
"title": "Navidrome Hotkeys",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Σάρωση βιβλιοθήκης",
"manageUsers": "Διαχείριση πρόσβασης χρήστη",
"viewDetails": "Προβολή λεπτομερειών"
"viewDetails": "Προβολή λεπτομερειών",
"quickScan": "Γρήγορη σάρωση",
"fullScan": "Πλήρης σάρωση"
},
"notifications": {
"created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
"updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
"deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
"scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
"scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε"
"scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
"quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
"fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
"scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
},
"validation": {
"nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
@@ -604,7 +609,8 @@
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
"elapsedTime": "Χρόνος που πέρασε"
"elapsedTime": "Χρόνος που πέρασε",
"selectiveScan": "Εκλεκτικός"
},
"help": {
"title": "Συντομεύσεις του Navidrome",

View File

@@ -27,15 +27,16 @@
"playDate": "Laste Ludita",
"channels": "Kanaloj",
"createdAt": "Dato de aligo",
"grouping": "",
"grouping": "Grupo",
"mood": "Humoro",
"participants": "",
"participants": "Aldonaj partoprenantoj",
"tags": "Aldonaj Etikedoj",
"mappedTags": "Mapigitaj etikedoj",
"rawTags": "Krudaj etikedoj",
"bitDepth": "",
"sampleRate": "",
"missing": ""
"bitDepth": "Bitprofundo",
"sampleRate": "Elprena rapido",
"missing": "Mankaj",
"libraryName": "Biblioteko"
},
"actions": {
"addToQueue": "Ludi Poste",
@@ -44,7 +45,8 @@
"shuffleAll": "Miksu Ĉiujn",
"download": "Elŝuti",
"playNext": "Ludu Poste",
"info": "Akiri Informon"
"info": "Akiri Informon",
"showInPlaylist": "Montri en Ludlisto"
}
},
"album": {
@@ -68,14 +70,15 @@
"releaseDate": "Publikiĝis",
"releases": "Publikiĝo |||| Publikiĝoj",
"released": "Publikiĝis",
"recordLabel": "",
"catalogNum": "",
"recordLabel": "Eldonejo",
"catalogNum": "Kataloga Numero",
"releaseType": "Tipo",
"grouping": "",
"media": "",
"grouping": "Grupo",
"media": "Aŭdvidaĵo",
"mood": "Humoro",
"date": "",
"missing": ""
"date": "Registraĵa Dato",
"missing": "Mankaj",
"libraryName": "Biblioteko"
},
"actions": {
"playAll": "Ludi",
@@ -107,8 +110,8 @@
"rating": "Takso",
"genre": "Ĝenro",
"size": "Grando",
"role": "",
"missing": ""
"role": "Rolo",
"missing": "Mankaj"
},
"roles": {
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
@@ -117,13 +120,19 @@
"conductor": "Dirigento |||| Dirigentoj",
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
"arranger": "Aranĝisto |||| Aranĝistoj",
"producer": "",
"director": "",
"engineer": "",
"producer": "Produktisto |||| Produktistoj",
"director": "Direktoro |||| Direktoroj",
"engineer": "Inĝeniero |||| Inĝenieroj",
"mixer": "Miksisto |||| Miksistoj",
"remixer": "Remiksisto |||| Remiksistoj",
"djmixer": "",
"performer": ""
"djmixer": "Dĵ-a Miksisto |||| Dĵ-a Miksistoj",
"performer": "Plenumisto |||| Plenumistoj",
"maincredit": "Albuma Artisto aŭ Artisto |||| Albumaj Artistoj aŭ Artistoj"
},
"actions": {
"shuffle": "Miksi",
"radio": "Radio",
"topSongs": "Plej Luditaj Kantoj"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Nuna Pasvorto",
"newPassword": "Nova Pasvorto",
"token": "Ĵetono",
"lastAccessAt": "Lasta Atingo"
"lastAccessAt": "Lasta Atingo",
"libraries": "Bibliotekoj"
},
"helperTexts": {
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto",
"libraries": "Elekti specifajn bibliotekojn por ĉi tiu uzanto, aŭ lasi malplena por uzi defaŭltajn bibliotekojn"
},
"notifications": {
"created": "Uzanto farita",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon",
"selectAllLibraries": "Elekti ĉiujn bibliotekojn",
"adminAutoLibraries": "Administrantoj aŭtomate havas aliron al ĉiuj bibliotekoj"
},
"validation": {
"librariesRequired": "Almenaŭ unu biblioteko devas esti elektita por neadministrantoj"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Eksporti",
"makePublic": "Publikigi",
"makePrivate": "Malpublikigi",
"saveQueue": ""
"saveQueue": "Konservi Ludvicon al Ludlisto",
"searchOrCreate": "Serĉi ludlistojn aŭ tajpi por krei novan...",
"pressEnterToCreate": "Premu je Enter por krei novan ludliston",
"removeFromSelection": "Forigi de elekto"
},
"message": {
"duplicate_song": "Aldoni duobligitajn kantojn",
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?",
"noPlaylistsFound": "Neniuj ludlistoj trovitaj",
"noPlaylists": "Neniuj ludlistoj haveblaj"
}
},
"radio": {
@@ -235,20 +256,78 @@
}
},
"missing": {
"name": "",
"name": "Manka Dosiero |||| Mankaj Dosieroj",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
"path": "Vojo",
"size": "Grando",
"updatedAt": "Malaperis je",
"libraryName": "Biblioteko"
},
"actions": {
"remove": "",
"remove_all": ""
"remove": "Forigi",
"remove_all": "Forigi Ĉiujn"
},
"notifications": {
"removed": ""
"removed": "Manka(j) dosiero(j) forigite"
},
"empty": ""
"empty": "Neniuj Mankaj Dosieroj"
},
"library": {
"name": "Biblioteko |||| Bibliotekoj",
"fields": {
"name": "Nomo",
"path": "Vojo",
"remotePath": "Fora Vojo",
"lastScanAt": "Plej Lasta Skano",
"songCount": "Kantoj",
"albumCount": "Albumoj",
"artistCount": "Artistoj",
"totalSongs": "Kantoj",
"totalAlbums": "Albumoj",
"totalArtists": "Artistoj",
"totalFolders": "Dosierujoj",
"totalFiles": "Dosieroj",
"totalMissingFiles": "Mankaj Dosieroj",
"totalSize": "Totala Grando",
"totalDuration": "Daŭro",
"defaultNewUsers": "Defaŭlto por Novaj Uzantoj",
"createdAt": "Farite je",
"updatedAt": "Ĝisdatiĝis je"
},
"sections": {
"basic": "Bazaj Informoj",
"statistics": "Statistikaĵoj"
},
"actions": {
"scan": "Skani Bibliotekon",
"manageUsers": "Agordi Uzantan Aliron",
"viewDetails": "Montri Informojn",
"quickScan": "Rapida Skano",
"fullScan": "Plena Skano"
},
"notifications": {
"created": "Biblioteko kreiĝis sukcese",
"updated": "Biblioteko ĝisdatiĝis sukcese",
"deleted": "Biblioteko foriĝis sukcese",
"scanStarted": "Biblioteka skano komenciĝis",
"scanCompleted": "Biblioteka skano finiĝis",
"quickScanStarted": "Rapida skano komenciĝis",
"fullScanStarted": "Plena skano komenciĝis",
"scanError": "Eraro de skana komenco. Kontrolu la protokolojn"
},
"validation": {
"nameRequired": "Biblioteka nomo estas necesa",
"pathRequired": "Biblioteka vojo estas necesa",
"pathNotDirectory": "Biblioteka vojo devas esti dosierujo",
"pathNotFound": "Biblioteka vojo ne trovite",
"pathNotAccessible": "Biblioteka vojo ne estas alirebla",
"pathInvalid": "Nevalida biblioteka vojo"
},
"messages": {
"deleteConfirm": "Ĉu vi certas, ke vi volas forigi ĉi tiun bibliotekon? Ĉi tio forigos ĉiujn rilatajn datumojn kaj uzantan aliron.",
"scanInProgress": "Skano progresas...",
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
}
}
},
"ra": {
@@ -427,10 +506,12 @@
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
"remove_missing_title": "",
"remove_missing_title": "Forigi mankajn dosierojn",
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
"remove_all_missing_title": "",
"remove_all_missing_content": ""
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
"remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
"noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
},
"menu": {
"library": "Biblioteko",
@@ -453,13 +534,19 @@
"album": "Uzi Albuman Songajnon",
"track": "Uzi Kantan Songajnon"
},
"lastfmNotConfigured": ""
"lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita"
}
},
"albumList": "Albumoj",
"about": "Pri",
"playlists": "Ludlistoj",
"sharedPlaylists": "Diskonigitaj Ludistoj"
"sharedPlaylists": "Diskonigitaj Ludistoj",
"librarySelector": {
"allLibraries": "Ĉiuj Bibliotekoj (%{count})",
"multipleLibraries": "%{selected} el %{total} Bibliotekoj",
"selectLibraries": "Elekti Bibliotekojn",
"none": "Neniu"
}
},
"player": {
"playListsText": "Atendovico",
@@ -491,11 +578,26 @@
"homepage": "Hejmpaĝo",
"source": "Fontkodo",
"featureRequests": "Trajta peto",
"lastInsightsCollection": "",
"lastInsightsCollection": "Plej lasta kolekto de datumoj",
"insights": {
"disabled": "Malebligita",
"waiting": ""
"waiting": "Atendante"
}
},
"tabs": {
"about": "Pri",
"config": "Agordo"
},
"config": {
"configName": "Agorda Nomo",
"environmentVariable": "Medivariablo",
"currentValue": "Nuna Valoro",
"configurationFile": "Agorda Dosiero",
"exportToml": "Eksporti Agordojn (TOML)",
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
"exportFailed": "Malsukcesis kopii agordojn",
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
}
},
"activity": {
@@ -505,9 +607,10 @@
"fullScan": "Plena Skanado",
"serverUptime": "Servila daŭro de funkciado",
"serverDown": "SENKONEKTA",
"scanType": "",
"status": "",
"elapsedTime": ""
"scanType": "Plej Lasta Skano",
"status": "Skana Eraro",
"elapsedTime": "Pasinta Tempo",
"selectiveScan": "Selektema"
},
"help": {
"title": "Navidrome klavkomando",
@@ -519,8 +622,13 @@
"next_song": "Sekva kanto",
"vol_up": "Pli volumo",
"vol_down": "Malpli volumo",
"toggle_love": "Baskuli la stelon de nuna kanto",
"toggle_love": "Aldoni ĉi tiun kanton al plej ŝatataj",
"current_song": "Iri al Nuna Kanto"
}
},
"nowPlaying": {
"title": "Nun Ludanta",
"empty": "Nenio ludas",
"minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj"
}
}

View File

@@ -36,7 +36,7 @@
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante",
"libraryName": ""
"libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Reproducir después",
@@ -78,7 +78,7 @@
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
"missing": "Faltante",
"libraryName": ""
"libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -127,12 +127,12 @@
"remixer": "Remixer",
"djmixer": "DJ Mixer",
"performer": "Intérprete",
"maincredit": ""
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
},
"actions": {
"shuffle": "Aleatorio",
"radio": "Radio",
"topSongs": ""
"topSongs": "Más destacadas"
}
},
"user": {
@@ -150,11 +150,11 @@
"newPassword": "Nueva contraseña",
"token": "Token",
"lastAccessAt": "Último acceso",
"libraries": ""
"libraries": "Bibliotecas"
},
"helperTexts": {
"name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
"libraries": ""
"libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
},
"notifications": {
"created": "Usuario creado",
@@ -164,11 +164,11 @@
"message": {
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
"clickHereForToken": "Click aquí para obtener tu token",
"selectAllLibraries": "",
"adminAutoLibraries": ""
"selectAllLibraries": "Seleccionar todas las bibliotecas",
"adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
},
"validation": {
"librariesRequired": ""
"librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
}
},
"player": {
@@ -261,7 +261,7 @@
"path": "Ruta",
"size": "Tamaño",
"updatedAt": "Actualizado el",
"libraryName": ""
"libraryName": "Biblioteca"
},
"actions": {
"remove": "Eliminar",
@@ -273,55 +273,60 @@
"empty": "No hay archivos perdidos"
},
"library": {
"name": "",
"name": "Biblioteca |||| Bibliotecas",
"fields": {
"name": "",
"path": "",
"remotePath": "",
"lastScanAt": "",
"songCount": "",
"albumCount": "",
"artistCount": "",
"totalSongs": "",
"totalAlbums": "",
"totalArtists": "",
"totalFolders": "",
"totalFiles": "",
"totalMissingFiles": "",
"totalSize": "",
"totalDuration": "",
"defaultNewUsers": "",
"createdAt": "",
"updatedAt": ""
"name": "Nombre",
"path": "Ruta",
"remotePath": "Ruta remota",
"lastScanAt": "Último escaneo",
"songCount": "Canciones",
"albumCount": "Álbumes",
"artistCount": "Artistas",
"totalSongs": "Canciones",
"totalAlbums": "Álbumes",
"totalArtists": "Artistas",
"totalFolders": "Carpetas",
"totalFiles": "Archivos",
"totalMissingFiles": "Archivos faltantes",
"totalSize": "Tamaño total",
"totalDuration": "Duración",
"defaultNewUsers": "Valor por defecto para los nuevos usuarios",
"createdAt": "Creado",
"updatedAt": "Actualizado"
},
"sections": {
"basic": "",
"statistics": ""
"basic": "Información básica",
"statistics": "Estadísticas"
},
"actions": {
"scan": "",
"manageUsers": "",
"viewDetails": ""
"scan": "Escanear biblioteca",
"manageUsers": "Gestionar el acceso de usarios",
"viewDetails": "Ver detalles",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo"
},
"notifications": {
"created": "",
"updated": "",
"deleted": "",
"scanStarted": "",
"scanCompleted": ""
"created": "La biblioteca se creó correctamente",
"updated": "La biblioteca se actualizó correctamente",
"deleted": "La biblioteca se eliminó correctamente",
"scanStarted": "El escaneo de la biblioteca ha comenzado",
"scanCompleted": "El escaneo de la biblioteca se completó",
"quickScanStarted": "Escaneo rápido ha comenzado",
"fullScanStarted": "Escaneo completo ha comenzado",
"scanError": "Error al iniciar el escaneo. Revisa los registros"
},
"validation": {
"nameRequired": "",
"pathRequired": "",
"pathNotDirectory": "",
"pathNotFound": "",
"pathNotAccessible": "",
"pathInvalid": ""
"nameRequired": "El nombre de la biblioteca es obligatorio",
"pathRequired": "La ruta de la biblioteca es obligatoria",
"pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
"pathNotFound": "Ruta de la biblioteca no encontrada",
"pathNotAccessible": "La ruta de la biblioteca no es accesible",
"pathInvalid": "Ruta de la biblioteca no válida"
},
"messages": {
"deleteConfirm": "",
"scanInProgress": "",
"noLibrariesAssigned": ""
"deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
"scanInProgress": "Escaneo en curso...",
"noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
}
}
},
@@ -506,7 +511,7 @@
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": ""
"noTopSongsFound": "No se encontraron canciones destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -537,10 +542,10 @@
"playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas",
"librarySelector": {
"allLibraries": "",
"multipleLibraries": "",
"selectLibraries": "",
"none": ""
"allLibraries": "Todas las bibliotecas (%{count})",
"multipleLibraries": "%{selected} de %{total} bibliotecas",
"selectLibraries": "Seleccionar bibliotecas",
"none": "Ninguno"
}
},
"player": {
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido"
"elapsedTime": "Tiempo transcurrido",
"selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -621,8 +627,8 @@
}
},
"nowPlaying": {
"title": "",
"empty": "",
"minutesAgo": ""
"title": "En reproducción",
"empty": "Nada en reproducción",
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}

View File

@@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
"lastAccessAt": "Azken sarbidea"
"lastAccessAt": "Azken sarbidea",
"libraries": "Liburutegiak"
},
"helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"validation": {
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
},
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko"
"clickHereForToken": "Egin klik hemen tokena lortzeko",
"selectAllLibraries": "Hautatu liburutegi guztiak",
"adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
}
},
"player": {
@@ -254,6 +263,7 @@
"fields": {
"path": "Bidea",
"size": "Tamaina",
"libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
@@ -263,6 +273,63 @@
"notifications": {
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
}
},
"library": {
"name": "Liburutegia |||| Liburutegiak",
"fields": {
"name": "Izena",
"path": "Fitxategiaren bidea",
"remotePath": "Urruneko bidea",
"lastScanAt": "Azken araketa",
"songCount": "Abestiak",
"albumCount": "Albumak",
"artistCount": "Artistak",
"totalSongs": "Abestiak",
"totalAlbums": "Albumak",
"totalArtists": "Artistak",
"totalFolders": "Karpetak",
"totalFiles": "Fitxategiak",
"totalMissingFiles": "Fitxategiak faltan",
"totalSize": "Tamaina guztira",
"totalDuration": "Iraupena",
"defaultNewUsers": "Defektuz erabiltzaile berrientzat",
"createdAt": "Sortze-data",
"updatedAt": "Eguneratze-data"
},
"sections": {
"basic": "Oinarrizko informazioa",
"statistics": "Estatistikak"
},
"actions": {
"scan": "Arakatu liburutegia",
"quickScan": "Araketa bizkorra",
"fullScan": "Araketa sakona",
"manageUsers": "Kudeatu erabiltzaileen sarbidea",
"viewDetails": "Ikusi xehetasunak"
},
"notifications": {
"created": "Liburutegia ondo sortu da",
"updated": "Liburutegia ondo eguneratu da",
"deleted": "Liburutegia ondo ezabatu da",
"scanStarted": "Liburutegiaren araketa hasi da",
"quickScanStarted": "Araketa bizkorra hasi da",
"fullScanStarted": "Araketa sakona hasi da",
"scanError": "Errorea araketa abiaraztean. Aztertu erregistroak",
"scanCompleted": "Liburutegiaren araketa amaitu da"
},
"validation": {
"nameRequired": "Liburutegiaren izena beharrezkoa da",
"pathRequired": "Liburutegiaren bidea beharrezkoa da",
"pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
"pathNotFound": "Ez da liburutegiaren bidea aurkitu",
"pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
"pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
},
"messages": {
"deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
}
},
"ra": {
@@ -397,7 +464,7 @@
"bad_item": "Elementu okerra",
"item_doesnt_exist": "Elementua ez dago",
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
"data_provider_error": "Errorea datuen hornitzailean. Aztertu kontsola xehetasun gehiagorako.",
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
"canceled": "Ekintza bertan behera utzi da",
"logged_out": "Saioa amaitu da, konektatu berriro.",
@@ -450,6 +517,12 @@
},
"menu": {
"library": "Liburutegia",
"librarySelector": {
"allLibraries": "Liburutegi guztiak (%{count})",
"multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
"selectLibraries": "Hautatu liburutegiak",
"none": "Bat ere ez"
},
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
@@ -532,8 +605,9 @@
"activity": {
"title": "Ekintzak",
"totalScanned": "Arakatutako karpeta guztiak",
"quickScan": "Arakatze azkarra",
"quickScan": "Arakatze bizkorra",
"fullScan": "Arakatze sakona",
"selectiveScan": "Arakatze selektiboa",
"serverUptime": "Zerbitzariak piztuta daraman denbora",
"serverDown": "LINEAZ KANPO",
"scanType": "Mota",

View File

@@ -31,7 +31,7 @@
"mood": "Tunnelma",
"participants": "Lisäosallistujat",
"tags": "Lisätunnisteet",
"mappedTags": "Mäpättyt tunnisteet",
"mappedTags": "Mäpätyt tunnisteet",
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
@@ -301,14 +301,19 @@
"actions": {
"scan": "Skannaa kirjasto",
"manageUsers": "Hallitse käyttäjien pääsyä",
"viewDetails": "Näytä tiedot"
"viewDetails": "Näytä tiedot",
"quickScan": "Nopea skannaus",
"fullScan": "Täysi skannaus"
},
"notifications": {
"created": "Kirjasto luotu onnistuneesti",
"updated": "Kirjasto päivitetty onnistuneesti",
"deleted": "Kirjasto poistettu onnistuneesti",
"scanStarted": "Kirjaston skannaus aloitettu",
"scanCompleted": "Kirjaston skannaus valmistunut"
"scanCompleted": "Kirjaston skannaus valmistunut",
"quickScanStarted": "Nopea skannaus aloitettu",
"fullScanStarted": "Täysi skannaus aloitettu",
"scanError": "Virhe skannauksen käynnistyksessä. Tarkista lokit"
},
"validation": {
"nameRequired": "Kirjaston nimi vaaditaan",
@@ -319,7 +324,7 @@
"pathInvalid": "Virheellinen kirjaston polku"
},
"messages": {
"deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.",
"deleteConfirm": "Haluatko varmasti poistaa tämän kirjaston? Kaikki siihen liittyvät tiedot ja käyttäjien pääsy poistetaan.",
"scanInProgress": "Skannaus käynnissä...",
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
}
@@ -336,7 +341,7 @@
"username": "Käyttäjänimi",
"password": "Salasana",
"sign_in": "Kirjaudu",
"sign_in_error": "Autentikointi epäonnistui. Yritä uudelleen",
"sign_in_error": "Kirjautuminen epäonnistui. Yritä uudelleen",
"logout": "Kirjaudu ulos",
"insightsCollectionNote": "Navidrome kerää anonyymejä käyttötietoja auttaakseen parantamaan\nprojektia. Paina [tästä] saadaksesi lisätietoa\nja halutessasi kieltäytyä"
},
@@ -346,7 +351,7 @@
"required": "Pakollinen",
"minLength": "Pitää vähintään olla %{min} merkkiä",
"maxLength": "Saa olla enintään %{max} merkkiä",
"minValue": "pitää olla vähintään %{min}",
"minValue": "Pitää olla vähintään %{min}",
"maxValue": "Saa olla enentään %{max}",
"number": "Pitää olla numero",
"email": "Pitää olla oikea sähköpostiosoite",
@@ -440,7 +445,7 @@
},
"navigation": {
"no_results": "Ei tuloksia",
"no_more_results": "Sivunumero %{page} on rajojen ulkopuolella. Kokeile edellinen sivu.",
"no_more_results": "Sivunumeroa %{page} ei löydy. Yritä edellistä sivua.",
"page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella",
"page_out_from_end": "Viimeinen sivu, ei voi edetä",
"page_out_from_begin": "Ensimmäinen sivu, ei voi palata",
@@ -522,7 +527,7 @@
"desktop_notifications": "Työpöytäilmoitukset",
"lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun",
"listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun",
"replaygain": "RepleyGain -tila",
"replaygain": "ReplayGain -tila",
"preAmp": "ReplayGain esivahvistus (dB)",
"gain": {
"none": "Pois käytöstä",
@@ -554,7 +559,7 @@
"previousTrackText": "Edellinen kappale",
"reloadText": "Päivitä",
"volumeText": "Äänenvoimakkuus",
"toggleLyricText": "Toggle lyric",
"toggleLyricText": "Näytä/piilota sanat",
"toggleMiniModeText": "Minimoi",
"destroyText": "Poista",
"downloadText": "Lataa",
@@ -604,7 +609,8 @@
"serverDown": "SAMMUTETTU",
"scanType": "Tyyppi",
"status": "Skannausvirhe",
"elapsedTime": "Kulunut aika"
"elapsedTime": "Kulunut aika",
"selectiveScan": "Valikoiva"
},
"help": {
"title": "Navidrome pikapainikkeet",
@@ -612,7 +618,7 @@
"show_help": "Näytä tämä apuvalikko",
"toggle_menu": "Menuvalikko päälle ja pois",
"toggle_play": "Toista / Tauko",
"prev_song": "Esellinen kappale",
"prev_song": "Edellinen kappale",
"next_song": "Seuraava kappale",
"vol_up": "Kovemmalle",
"vol_down": "Hiljemmalle",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanner la bibliothèque",
"manageUsers": "Gérer les accès utilisateurs",
"viewDetails": "Voir les détails"
"viewDetails": "Voir les détails",
"quickScan": "Scan Rapide",
"fullScan": "Scan Complet"
},
"notifications": {
"created": "Bibliothèque créée avec succès",
"updated": "Bibliothèque mise à jour avec succès",
"deleted": "Bibliothèque supprimée avec succès",
"scanStarted": "Le scan de la bibliothèque a commencé",
"scanCompleted": "Le scan de la bibliothèque est terminé"
"scanCompleted": "Le scan de la bibliothèque est terminé",
"quickScanStarted": "Scan rapide démarré",
"fullScanStarted": "Scan complet démarré",
"scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
},
"validation": {
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
@@ -604,7 +609,8 @@
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
"elapsedTime": "Temps écoulé"
"elapsedTime": "Temps écoulé",
"selectiveScan": "Sélectif"
},
"help": {
"title": "Raccourcis Navidrome",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Escanear Biblioteca",
"manageUsers": "Xestionar acceso das usuarias",
"viewDetails": "Ver detalles"
"viewDetails": "Ver detalles",
"quickScan": "Escaneado rápido",
"fullScan": "Escaneado completo"
},
"notifications": {
"created": "Biblioteca creada correctamente",
"updated": "Biblioteca actualizada correctamente",
"deleted": "Biblioteca eliminada correctamente",
"scanStarted": "Comezou o escaneo da biblioteca",
"scanCompleted": "Completouse o escaneado da biblioteca"
"scanCompleted": "Completouse o escaneado da biblioteca",
"quickScanStarted": "Iniciado o escaneado rápido",
"fullScanStarted": "Iniciado o escaneado completo",
"scanError": "Erro ao escanear. Comproba o rexistro"
},
"validation": {
"nameRequired": "Requírese un nome para a biblioteca",
@@ -604,7 +609,8 @@
"serverDown": "SEN CONEXIÓN",
"scanType": "Tipo",
"status": "Erro de escaneado",
"elapsedTime": "Tempo transcurrido"
"elapsedTime": "Tempo transcurrido",
"selectiveScan": "Selectivo"
},
"help": {
"title": "Atallos de Navidrome",

View File

@@ -300,7 +300,9 @@
},
"actions": {
"scan": "Könyvtár szkennelése",
"manageUsers": "Elérés kezelése",
"quickScan": "Gyors szkennelés",
"fullScan": "Teljes szkennelés",
"manageUsers": "Hozzáférés kezelése",
"viewDetails": "Részletek"
},
"notifications": {
@@ -598,11 +600,12 @@
"activity": {
"title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:",
"quickScan": "Gyors szkennelés",
"fullScan": "Teljes szkennelés",
"quickScan": "Gyors",
"fullScan": "Teljes",
"selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
"scanType": "Típus",
"scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
},

View File

@@ -27,12 +27,16 @@
"playDate": "最後の再生",
"channels": "チャンネル",
"createdAt": "追加日",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
"grouping": "グループ分け",
"mood": "ムード",
"participants": "追加参加者",
"tags": "追加タグ",
"mappedTags": "マッピング済みタグ",
"rawTags": "未処理タグ",
"bitDepth": "ビット深度",
"sampleRate": "サンプリングレート",
"missing": "不明",
"libraryName": "ライブラリ"
},
"actions": {
"addToQueue": "最後に再生",
@@ -41,7 +45,8 @@
"shuffleAll": "全曲シャッフル",
"download": "ダウンロード",
"playNext": "次に再生",
"info": "詳細"
"info": "詳細",
"showInPlaylist": "含まれるプレイリスト"
}
},
"album": {
@@ -65,12 +70,15 @@
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
"recordLabel": "ラベル",
"catalogNum": "カタログ番号",
"releaseType": "タイプ",
"grouping": "グループ分け",
"media": "メディア",
"mood": "ムード",
"date": "録音日",
"missing": "不明",
"libraryName": "ライブラリ"
},
"actions": {
"playAll": "再生",
@@ -102,22 +110,29 @@
"rating": "レート",
"genre": "ジャンル",
"size": "サイズ",
"role": ""
"role": "役割",
"missing": "不明"
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
"albumartist": "アルバムアーティスト",
"artist": "アーティスト",
"composer": "作曲家",
"conductor": "指揮者",
"lyricist": "作詞家",
"arranger": "編曲者",
"producer": "プロデューサー",
"director": "ディレクター",
"engineer": "エンジニア",
"mixer": "ミキサー",
"remixer": "リミキサー",
"djmixer": "DJ ミキサー",
"performer": "演奏者",
"maincredit": "アルバムアーティストもしくはアーティスト"
},
"actions": {
"shuffle": "シャッフル",
"radio": "ラジオ",
"topSongs": "トップソング"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"token": "トークン",
"lastAccessAt": "最終アクセス"
"lastAccessAt": "最終アクセス",
"libraries": "ライブラリ"
},
"helperTexts": {
"name": "名前の変更は次回ログイン以降反映されます"
"name": "名前の変更は次回ログイン以降反映されます",
"libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
},
"notifications": {
"created": "ユーザーが作成されました",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ListenBrainzユーザートークンを入力",
"clickHereForToken": "ここをクリックしトークンを入手"
"clickHereForToken": "ここをクリックしトークンを入手",
"selectAllLibraries": "全てのライブラリを選択",
"adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
},
"validation": {
"librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "'%{name}' を作成",
"export": "エクスポート",
"makePublic": "公開する",
"makePrivate": "非公開にする"
"makePrivate": "非公開にする",
"saveQueue": "キューをプレイリストに保存",
"searchOrCreate": "プレイリストを検索または入力して新規作成...",
"pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
"removeFromSelection": "選択から削除"
},
"message": {
"duplicate_song": "重複する曲を追加",
"song_exist": "既にプレイリストに存在する曲です。追加しますか?"
"song_exist": "既にプレイリストに存在する曲です。追加しますか?",
"noPlaylistsFound": "プレイリストが見つかりません",
"noPlaylists": "利用可能なプレイリストはありません"
}
},
"radio": {
@@ -228,17 +256,77 @@
}
},
"missing": {
"name": "",
"name": "欠落したファイル",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
"path": "パス",
"size": "サイズ",
"updatedAt": "欠落日",
"libraryName": "ライブラリ"
},
"actions": {
"remove": ""
"remove": "削除",
"remove_all": "全て削除"
},
"notifications": {
"removed": ""
"removed": "欠落ファイルが削除されました"
},
"empty": "ファイルの欠落はありません"
},
"library": {
"name": "ライブラリ",
"fields": {
"name": "名前",
"path": "パス",
"remotePath": "リモートパス",
"lastScanAt": "最終スキャン",
"songCount": "曲数",
"albumCount": "アルバム数",
"artistCount": "アーティスト数",
"totalSongs": "曲数",
"totalAlbums": "アルバム数",
"totalArtists": "アーティスト数",
"totalFolders": "フォルダー数",
"totalFiles": "ファイル数",
"totalMissingFiles": "欠落したファイル",
"totalSize": "合計サイズ",
"totalDuration": "合計時間",
"defaultNewUsers": "新規ユーザーに対するデフォルト",
"createdAt": "作成日",
"updatedAt": "更新日"
},
"sections": {
"basic": "基本情報",
"statistics": "統計"
},
"actions": {
"scan": "ライブラリをスキャン",
"manageUsers": "ユーザーアクセス管理",
"viewDetails": "詳細を表示",
"quickScan": "クイックスキャン",
"fullScan": "フルスキャン"
},
"notifications": {
"created": "ライブラリが正常に作成されました",
"updated": "ライブラリが正常に更新されました",
"deleted": "ライブラリが正常に削除されました",
"scanStarted": "スキャンを開始しました",
"scanCompleted": "スキャンが完了しました",
"quickScanStarted": "クイックスキャンを開始しました",
"fullScanStarted": "フルスキャンを開始しました",
"scanError": "スキャン開始中にエラーが発生。ログを確認してください"
},
"validation": {
"nameRequired": "ライブラリの名前が必要です",
"pathRequired": "ライブラリのパスが必要です",
"pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
"pathNotFound": "ライブラリのパスが見つかりません",
"pathNotAccessible": "ライブラリパスへアクセスできません",
"pathInvalid": "無効なライブラリパス"
},
"messages": {
"deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
"scanInProgress": "スキャン中...",
"noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
}
}
},
@@ -418,8 +506,12 @@
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
"remove_missing_title": "",
"remove_missing_content": ""
"remove_missing_title": "欠落ファイルを削除",
"remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
"remove_all_missing_title": "全ての欠落ファイルを削除",
"remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
"noSimilarSongsFound": "類似の曲が見つかりませんでした",
"noTopSongsFound": "トップソングが見つかりません"
},
"menu": {
"library": "ライブラリ",
@@ -448,7 +540,13 @@
"albumList": "アルバム",
"about": "詳細",
"playlists": "プレイリスト",
"sharedPlaylists": "共有プレイリスト"
"sharedPlaylists": "共有プレイリスト",
"librarySelector": {
"allLibraries": "全てのライブラリ( %{count} )",
"multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
"selectLibraries": "ライブラリを選択",
"none": "無し"
}
},
"player": {
"playListsText": "再生リスト",
@@ -485,15 +583,34 @@
"disabled": "無効",
"waiting": "待機中"
}
},
"tabs": {
"about": "詳細",
"config": "設定"
},
"config": {
"configName": "設定名",
"environmentVariable": "環境変数",
"currentValue": "現在値",
"configurationFile": "設定ファイル",
"exportToml": "設定をエクスポート(TOML)",
"exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
"exportFailed": "設定のコピーに失敗しました",
"devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
"devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
}
},
"activity": {
"title": "活動",
"totalScanned": "スキャン済みフォルダー",
"quickScan": "クイックスキャン",
"fullScan": "フルスキャン",
"quickScan": "クイック",
"fullScan": "フル",
"serverUptime": "サーバー稼働時間",
"serverDown": "サーバーオフライン"
"serverDown": "サーバーオフライン",
"scanType": "最終スキャン",
"status": "スキャンエラー",
"elapsedTime": "経過時間",
"selectiveScan": "選択的スキャン"
},
"help": {
"title": "ホットキー",
@@ -508,5 +625,10 @@
"toggle_love": "星の付け外し",
"current_song": "現在の曲へ移動"
}
},
"nowPlaying": {
"title": "再生中",
"empty": "何も再生されていません",
"minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
}
}

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Scan bibliotheek",
"manageUsers": "Beheer gebruikerstoegang",
"viewDetails": "Bekijk details"
"viewDetails": "Bekijk details",
"quickScan": "Snelle scan",
"fullScan": "Volledige scan"
},
"notifications": {
"created": "Bibliotheek succesvol aangemaakt",
"updated": "Bibliotheek succesvol bijgewerkt",
"deleted": "Bibliotheek succesvol verwijderd",
"scanStarted": "Bibliotheekscan is gestart",
"scanCompleted": "Bibliotheekscan is voltooid"
"scanCompleted": "Bibliotheekscan is voltooid",
"quickScanStarted": "Snelle scan gestart",
"fullScanStarted": "Volledige scan gestart",
"scanError": "Fout bij start van scan. Check de logs"
},
"validation": {
"nameRequired": "Bibliotheek naam is vereist",
@@ -604,7 +609,8 @@
"serverDown": "Offline",
"scanType": "Type",
"status": "Scan fout",
"elapsedTime": "Verlopen tijd"
"elapsedTime": "Verlopen tijd",
"selectiveScan": "Selectief"
},
"help": {
"title": "Navidrome sneltoetsen",

View File

@@ -18,8 +18,6 @@
"size": "Filstørrelse",
"updatedAt": "Oppdatert",
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"channels": "Kanaler",
"discSubtitle": "Disk Undertittel",
"starred": "Favoritt",
"comment": "Kommentar",
@@ -27,13 +25,18 @@
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist Avspilt",
"channels": "Kanaler",
"createdAt": "Lagt til",
"grouping": "Gruppering",
"mood": "Stemning",
"participants": "Ytterlige deltakere",
"tags": "Ytterlige Tags",
"mappedTags": "Kartlagte tags",
"rawTags": "Rå tags"
"rawTags": "Rå tags",
"bitDepth": "Bit depth",
"sampleRate": "",
"missing": "",
"libraryName": ""
},
"actions": {
"addToQueue": "Avspill senere",
@@ -42,7 +45,8 @@
"shuffleAll": "Shuffle Alle",
"download": "Last ned",
"playNext": "Avspill neste",
"info": "Få Info"
"info": "Få Info",
"showInPlaylist": ""
}
},
"album": {
@@ -53,36 +57,38 @@
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
"size": "Størrelse",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
"date": "Inspillingsdato",
"originalDate": "Original",
"releaseDate": "Utgitt",
"releases": "Utgivelse |||| Utgivelser",
"released": "Utgitt",
"updatedAt": "Oppdatert",
"comment": "Kommentar",
"rating": "Rangering",
"createdAt": "Lagt Til",
"size": "Størrelse",
"originalDate": "Original",
"releaseDate": "Utgitt",
"releases": "Utgivelse |||| Utgivelser",
"released": "Utgitt",
"recordLabel": "Plateselskap",
"catalogNum": "Katalognummer",
"releaseType": "Type",
"grouping": "Gruppering",
"media": "Media",
"mood": "Stemning"
"mood": "Stemning",
"date": "Inspillingsdato",
"missing": "",
"libraryName": ""
},
"actions": {
"playAll": "Avspill",
"playNext": "Avspill Neste",
"addToQueue": "Avspill Senere",
"share": "Del",
"shuffle": "Shuffle",
"addToPlaylist": "Legg til i spilleliste",
"download": "Last ned",
"info": "Få Info"
"info": "Få Info",
"share": "Del"
},
"lists": {
"all": "Alle",
@@ -100,11 +106,12 @@
"name": "Navn",
"albumCount": "Album Antall",
"songCount": "Song Antall",
"size": "Størrelse",
"playCount": "Avspillinger",
"rating": "Rangering",
"genre": "Sjanger",
"role": "Rolle"
"size": "Størrelse",
"role": "Rolle",
"missing": ""
},
"roles": {
"albumartist": "Album Artist |||| Album Artister",
@@ -119,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
"performer": "Performer |||| Performers",
"maincredit": ""
},
"actions": {
"shuffle": "",
"radio": "",
"topSongs": ""
}
},
"user": {
@@ -128,7 +141,6 @@
"userName": "Brukernavn",
"isAdmin": "Admin",
"lastLoginAt": "Sist Pålogging",
"lastAccessAt": "Sist Tilgang",
"updatedAt": "Oppdatert",
"name": "Navn",
"password": "Passord",
@@ -136,10 +148,13 @@
"changePassword": "Bytt Passord?",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
"token": "Token"
"token": "Token",
"lastAccessAt": "Sist Tilgang",
"libraries": ""
},
"helperTexts": {
"name": "Navnendringer vil ikke være synlig før neste pålogging"
"name": "Navnendringer vil ikke være synlig før neste pålogging",
"libraries": ""
},
"notifications": {
"created": "Bruker opprettet",
@@ -148,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
"clickHereForToken": "Klikk her for å hente din token"
"clickHereForToken": "Klikk her for å hente din token",
"selectAllLibraries": "",
"adminAutoLibraries": ""
},
"validation": {
"librariesRequired": ""
}
},
"player": {
@@ -192,11 +212,17 @@
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksporter",
"makePublic": "Gjør Offentlig",
"makePrivate": "Gjør Privat"
"makePrivate": "Gjør Privat",
"saveQueue": "",
"searchOrCreate": "",
"pressEnterToCreate": "",
"removeFromSelection": ""
},
"message": {
"duplicate_song": "Legg til Duplikater",
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?",
"noPlaylistsFound": "",
"noPlaylists": ""
}
},
"radio": {
@@ -218,7 +244,6 @@
"username": "Delt Av",
"url": "URL",
"description": "Beskrivelse",
"downloadable": "Tillat Nedlastinger?",
"contents": "Innhold",
"expiresAt": "Utløper",
"lastVisitedAt": "Sist Besøkt",
@@ -226,24 +251,82 @@
"format": "Format",
"maxBitRate": "Maks. Bit Rate",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet"
},
"notifications": {},
"actions": {}
"createdAt": "Opprettet",
"downloadable": "Tillat Nedlastinger?"
}
},
"missing": {
"name": "Manglende Fil|||| Manglende Filer",
"empty": "Ingen Manglende Filer",
"fields": {
"path": "Filsti",
"size": "Størrelse",
"updatedAt": "Ble borte"
"updatedAt": "Ble borte",
"libraryName": ""
},
"actions": {
"remove": "Fjern"
"remove": "Fjern",
"remove_all": ""
},
"notifications": {
"removed": "Manglende fil(er) fjernet"
},
"empty": "Ingen Manglende Filer"
},
"library": {
"name": "",
"fields": {
"name": "",
"path": "",
"remotePath": "",
"lastScanAt": "",
"songCount": "",
"albumCount": "",
"artistCount": "",
"totalSongs": "",
"totalAlbums": "",
"totalArtists": "",
"totalFolders": "",
"totalFiles": "",
"totalMissingFiles": "",
"totalSize": "",
"totalDuration": "",
"defaultNewUsers": "",
"createdAt": "",
"updatedAt": ""
},
"sections": {
"basic": "",
"statistics": ""
},
"actions": {
"scan": "",
"manageUsers": "",
"viewDetails": "",
"quickScan": "",
"fullScan": ""
},
"notifications": {
"created": "",
"updated": "",
"deleted": "Biblioteket slettet",
"scanStarted": "Skanning startet",
"scanCompleted": "",
"quickScanStarted": "",
"fullScanStarted": "",
"scanError": "Error starte skanning. Sjekk loggene"
},
"validation": {
"nameRequired": "",
"pathRequired": "",
"pathNotDirectory": "",
"pathNotFound": "",
"pathNotAccessible": "",
"pathInvalid": ""
},
"messages": {
"deleteConfirm": "",
"scanInProgress": "",
"noLibrariesAssigned": ""
}
}
},
@@ -282,7 +365,6 @@
"add": "Legg Til",
"back": "Tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Avbryt",
"clear_input_value": "Nullstill verdi",
"clone": "Klone",
@@ -306,6 +388,7 @@
"close_menu": "Lukk meny",
"unselect": "Avvelg",
"skip": "Hopp over",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Del",
"download": "Last Ned"
},
@@ -400,31 +483,35 @@
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
"remove_missing_title": "Fjern manglende filer",
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les Mer...",
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"downloadOriginalFormat": "Last ned i originalformat",
"shareOriginalFormat": "Del i originalformat",
"shareDialogTitle": "Del %{resource} '%{name}'",
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Last ned i originalformat"
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"remove_missing_title": "Fjern manglende filer",
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"remove_all_missing_title": "",
"remove_all_missing_content": "",
"noSimilarSongsFound": "",
"noTopSongsFound": ""
},
"menu": {
"library": "Bibliotek",
@@ -438,7 +525,6 @@
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsnotifikasjoner",
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "ReplayGain Mode",
@@ -447,13 +533,20 @@
"none": "Deaktivert",
"album": "Bruk Album Gain",
"track": "Bruk Track Gain"
}
},
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert"
}
},
"albumList": "Album",
"about": "Om",
"playlists": "Spillelister",
"sharedPlaylists": "Delte Spillelister",
"about": "Om"
"librarySelector": {
"allLibraries": "",
"multipleLibraries": "",
"selectLibraries": "",
"none": ""
}
},
"player": {
"playListsText": "Spill Av Kø",
@@ -490,6 +583,21 @@
"disabled": "Deaktivert",
"waiting": "Venter"
}
},
"tabs": {
"about": "",
"config": ""
},
"config": {
"configName": "",
"environmentVariable": "",
"currentValue": "",
"configurationFile": "",
"exportToml": "",
"exportSuccess": "",
"exportFailed": "",
"devFlagsHeader": "",
"devFlagsComment": ""
}
},
"activity": {
@@ -498,7 +606,11 @@
"quickScan": "Hurtigskann",
"fullScan": "Full Skann",
"serverUptime": "Server Oppetid",
"serverDown": "OFFLINE"
"serverDown": "OFFLINE",
"scanType": "",
"status": "",
"elapsedTime": "",
"selectiveScan": "Utvalgt"
},
"help": {
"title": "Navidrome Hurtigtaster",
@@ -508,10 +620,15 @@
"toggle_play": "Avspill / Pause",
"prev_song": "Forrige Sang",
"next_song": "Neste Sang",
"current_song": "Gå til Nåværende Sang",
"vol_up": "Volum Opp",
"vol_down": "Volum Ned",
"toggle_love": "Legg til spor i favoritter"
"toggle_love": "Legg til spor i favoritter",
"current_song": "Gå til Nåværende Sang"
}
},
"nowPlaying": {
"title": "",
"empty": "",
"minutesAgo": ""
}
}
}

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Skanuj Bibliotekę",
"manageUsers": "Zarządzaj Dostępami Użytkownika",
"viewDetails": "Zobacz Szczegóły"
"viewDetails": "Zobacz Szczegóły",
"quickScan": "Szybkie Skanowanie",
"fullScan": "Pełne Skanowanie"
},
"notifications": {
"created": "Biblioteka utworzona prawidłowo",
"updated": "Biblioteka zaktualizowana prawidłowo",
"deleted": "Biblioteka usunięta prawidłowo",
"scanStarted": "Rozpoczęto skan biblioteki",
"scanCompleted": "Zakończono skan biblioteki"
"scanCompleted": "Zakończono skan biblioteki",
"quickScanStarted": "Szybkie skanowanie rozpoczęte",
"fullScanStarted": "Pełne skanowanie rozpoczęte",
"scanError": "Błąd podczas startu skanowania. Sprawdź logi"
},
"validation": {
"nameRequired": "Nazwa biblioteki jest wymagana",
@@ -604,7 +609,8 @@
"serverDown": "NIEDOSTĘPNY",
"scanType": "Typ",
"status": "Błąd Skanowania",
"elapsedTime": "Upłynięty Czas"
"elapsedTime": "Upłynięty Czas",
"selectiveScan": "Selektywne"
},
"help": {
"title": "Skróty Klawiszowe Navidrome",

View File

@@ -301,20 +301,25 @@
"actions": {
"scan": "Сканировать библиотеку",
"manageUsers": "Управление доступом пользователей",
"viewDetails": "Просмотреть подробности"
"viewDetails": "Просмотреть подробности",
"quickScan": "Быстрое сканирование",
"fullScan": "Полное сканирование"
},
"notifications": {
"created": "Библиотека успешно создана",
"updated": "Библиотека успешно обновлена",
"deleted": "Библиотека успешно удалена",
"scanStarted": "Сканирование библиотеки начато",
"scanCompleted": "Сканирование библиотеки закончено"
"scanCompleted": "Сканирование библиотеки закончено",
"quickScanStarted": "Быстрое сканирование началось",
"fullScanStarted": "Началось полное сканирование",
"scanError": "Ошибка при запуске сканирования. Проверьте логи"
},
"validation": {
"nameRequired": "Имя библиотеки обязательно",
"pathRequired": "Путь к библиотеке обязателен",
"pathNotDirectory": "Путь к библиотеке должен быть директорией",
"pathNotFound": "Путь к библиотеке не найдено",
"pathNotFound": "Путь к библиотеке не найден",
"pathNotAccessible": "Путь к библиотеке недоступен",
"pathInvalid": "Неверный путь к библиотеке"
},
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
"elapsedTime": "Прошедшее время"
"elapsedTime": "Прошедшее время",
"selectiveScan": "Избирательный"
},
"help": {
"title": "Горячие клавиши Navidrome",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanna bibliotek",
"manageUsers": "Hantera användaråtkomst",
"viewDetails": "Se detaljer"
"viewDetails": "Se detaljer",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad",
"scanCompleted": "Biblioteksscan avslutad"
"scanCompleted": "Biblioteksscan avslutad",
"quickScanStarted": "Snabbscan startad",
"fullScanStarted": "Komplett scan startad",
"scanError": "Fel vid start av scan. Se loggarna"
},
"validation": {
"nameRequired": "Biblioteksnamn krävs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid"
"elapsedTime": "Spelad tid",
"selectiveScan": "Urval"
},
"help": {
"title": "Navidrome kortkommandon",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "สแกนห้องสมุด",
"manageUsers": "ตั้งค่าการเข้าถึง",
"viewDetails": "ดูรายละเอียด"
"viewDetails": "ดูรายละเอียด",
"quickScan": "สแกนแบบเร็ว",
"fullScan": "สแกนแบบเต็ม"
},
"notifications": {
"created": "สร้างห้องสมุดเรียบร้อย",
"updated": "อัพเดทห้องสมุดเรียบร้อย",
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
"scanStarted": "เริ่มสแกนห้องสมุด",
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
"quickScanStarted": "เริ่มสแกนแบบเร็ว",
"fullScanStarted": "เริ่มสแกนแบบเต็ม",
"scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
},
"validation": {
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
@@ -604,7 +609,8 @@
"serverDown": "ออฟไลน์",
"scanType": "ประเภท",
"status": "สแกนผิดพลาด",
"elapsedTime": "เวลาที่ใช้"
"elapsedTime": "เวลาที่ใช้",
"selectiveScan": "เลือก"
},
"help": {
"title": "คีย์ลัด Navidrome",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Kütüphaneyi Tara",
"manageUsers": "Kullanıcı Erişimini Yönet",
"viewDetails": "Ayrıntıları Görüntüle"
"viewDetails": "Ayrıntıları Görüntüle",
"quickScan": "Hızlı Tarama",
"fullScan": "Tam Tarama"
},
"notifications": {
"created": "Kütüphane başarıyla oluşturuldu",
"updated": "Kütüphane başarıyla güncellendi",
"deleted": "Kütüphane başarıyla silindi",
"scanStarted": "Kütüphane taraması başladı",
"scanCompleted": "Kütüphane taraması tamamlandı"
"scanCompleted": "Kütüphane taraması tamamlandı",
"quickScanStarted": "Hızlı tarama başlatıldı",
"fullScanStarted": "Tam tarama başlatıldı",
"scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin."
},
"validation": {
"nameRequired": "Kütüphane adı gereklidir",
@@ -604,7 +609,8 @@
"serverDown": "ÇEVRİMDIŞI",
"scanType": "Tür",
"status": "Tarama Hatası",
"elapsedTime": "Geçen Süre"
"elapsedTime": "Geçen Süre",
"selectiveScan": "Seçmeli"
},
"help": {
"title": "Navidrome Kısayolları",

View File

@@ -301,14 +301,19 @@
"actions": {
"scan": "Сканувати бібліотеку",
"manageUsers": "Керування доступом користувачів",
"viewDetails": "Переглянути подробиці"
"viewDetails": "Переглянути подробиці",
"quickScan": "Швидке сканування",
"fullScan": "Повне сканування"
},
"notifications": {
"created": "Бібліотеку успішно створено",
"updated": "Бібліотеку успішно оновлено",
"deleted": "Бібліотеку успішно видалено",
"scanStarted": "Сканування бібліотеки розпочато",
"scanCompleted": "Сканування бібліотеки закінчено"
"scanCompleted": "Сканування бібліотеки закінчено",
"quickScanStarted": "Швидке сканування виконується",
"fullScanStarted": "Повне сканування виконується",
"scanError": "Помилка при виконанні сканування. Перевірте лоґи"
},
"validation": {
"nameRequired": "Ім'я бібліотеки обов'язкове",
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
"elapsedTime": "Пройдений час"
"elapsedTime": "Пройдений час",
"selectiveScan": "Вибірковий"
},
"help": {
"title": "Гарячі клавіші Navidrome",

View File

@@ -14,6 +14,12 @@ import (
"github.com/navidrome/navidrome/model"
)
const (
// argLengthThreshold is the threshold for switching from command-line args to file-based target passing.
// Set conservatively at 24KB to support Windows (~32KB limit) with margin for env vars.
argLengthThreshold = 24 * 1024
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
// external process will be spawned with the same executable as the current process, and will run
@@ -45,10 +51,14 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
// Add targets if provided
if len(targets) > 0 {
for _, target := range targets {
args = append(args, "-t", target.String())
targetArgs, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
if err != nil {
progress <- &ProgressInfo{Error: err.Error()}
return
}
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
defer cleanup()
log.Debug(ctx, "Spawning external scanner process with target file", "fullScan", fullScan, "path", exe, "numTargets", len(targets))
args = append(args, targetArgs...)
} else {
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
}
@@ -98,4 +108,62 @@ func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) {
_ = out.Close()
}
// targetArguments builds command-line arguments for the given scan targets.
// If the estimated argument length exceeds a threshold, it writes the targets to a temp file
// and returns the --target-file argument instead.
// Returns the arguments, a cleanup function to remove any temp file created, and an error if any.
func targetArguments(ctx context.Context, targets []model.ScanTarget, lengthThreshold int) ([]string, func(), error) {
var args []string
// Estimate argument length to decide whether to use file-based approach
argLength := estimateArgLength(targets)
if argLength > lengthThreshold {
// Write targets to temp file and pass via --target-file
targetFile, err := writeTargetsToFile(targets)
if err != nil {
return nil, nil, fmt.Errorf("failed to write targets to file: %w", err)
}
args = append(args, "--target-file", targetFile)
return args, func() {
os.Remove(targetFile) // Clean up temp file
}, nil
}
// Use command-line arguments for small target lists
for _, target := range targets {
args = append(args, "-t", target.String())
}
return args, func() {}, nil
}
// estimateArgLength estimates the total length of command-line arguments for the given targets.
func estimateArgLength(targets []model.ScanTarget) int {
length := 0
for _, target := range targets {
// Each target adds: "-t " + target string + space
length += 3 + len(target.String()) + 1
}
return length
}
// writeTargetsToFile writes the targets to a temporary file, one per line.
// Returns the path to the temp file, which the caller should clean up.
func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
tmpFile, err := os.CreateTemp("", "navidrome-scan-targets-*.txt")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
for _, target := range targets {
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}
return tmpFile.Name(), nil
}
var _ scanner = (*scannerExternal)(nil)

160
scanner/external_test.go Normal file
View File

@@ -0,0 +1,160 @@
package scanner
import (
"context"
"os"
"strings"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("targetArguments", func() {
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
})
Context("with small target list", func() {
It("returns command-line arguments for single target", func() {
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music/Rock"},
}
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(Equal([]string{"-t", "1:Music/Rock"}))
})
It("returns command-line arguments for multiple targets", func() {
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music/Rock"},
{LibraryID: 2, FolderPath: "Music/Jazz"},
{LibraryID: 3, FolderPath: "Classical"},
}
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(Equal([]string{
"-t", "1:Music/Rock",
"-t", "2:Music/Jazz",
"-t", "3:Classical",
}))
})
It("handles targets with special characters", func() {
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music/Rock & Roll"},
{LibraryID: 2, FolderPath: "Music/Jazz (Modern)"},
}
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(Equal([]string{
"-t", "1:Music/Rock & Roll",
"-t", "2:Music/Jazz (Modern)",
}))
})
})
Context("with large target list exceeding threshold", func() {
It("returns --target-file argument when exceeding threshold", func() {
// Create enough targets to exceed the threshold
var targets []model.ScanTarget
for i := 1; i <= 600; i++ {
targets = append(targets, model.ScanTarget{
LibraryID: 1,
FolderPath: "Music/VeryLongFolderPathToSimulateRealScenario/SubFolder",
})
}
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(HaveLen(2))
Expect(args[0]).To(Equal("--target-file"))
// Verify the file exists and has correct format
filePath := args[1]
Expect(filePath).To(ContainSubstring("navidrome-scan-targets-"))
Expect(filePath).To(HaveSuffix(".txt"))
// Verify file actually exists
_, err = os.Stat(filePath)
Expect(err).ToNot(HaveOccurred())
})
It("creates temp file with correct format", func() {
// Use custom threshold to easily exceed it
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music/Rock"},
{LibraryID: 2, FolderPath: "Music/Jazz"},
{LibraryID: 3, FolderPath: "Classical"},
}
// Set threshold very low to force file usage
args, cleanup, err := targetArguments(ctx, targets, 10)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args[0]).To(Equal("--target-file"))
// Verify file exists with correct format
filePath := args[1]
Expect(filePath).To(ContainSubstring("navidrome-scan-targets-"))
Expect(filePath).To(HaveSuffix(".txt"))
// Verify file content
content, err := os.ReadFile(filePath)
Expect(err).ToNot(HaveOccurred())
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
Expect(lines).To(HaveLen(3))
Expect(lines[0]).To(Equal("1:Music/Rock"))
Expect(lines[1]).To(Equal("2:Music/Jazz"))
Expect(lines[2]).To(Equal("3:Classical"))
})
})
Context("edge cases", func() {
It("handles empty target list", func() {
var targets []model.ScanTarget
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(BeEmpty())
})
It("uses command-line args when exactly at threshold", func() {
// Create targets that are exactly at threshold
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music"},
}
// Estimate length should be 11 bytes
estimatedLength := estimateArgLength(targets)
args, cleanup, err := targetArguments(ctx, targets, estimatedLength)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args).To(Equal([]string{"-t", "1:Music"}))
})
It("uses file when one byte over threshold", func() {
targets := []model.ScanTarget{
{LibraryID: 1, FolderPath: "Music"},
}
// Set threshold just below the estimated length
estimatedLength := estimateArgLength(targets)
args, cleanup, err := targetArguments(ctx, targets, estimatedLength-1)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
Expect(args[0]).To(Equal("--target-file"))
})
})
})

View File

@@ -76,6 +76,12 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer,
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting fs for library: %w", err)
}
// Ensure FullScanInProgress reflects the current scan request.
// This is important when resuming an interrupted quick scan as a full scan:
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
lib: lib,
fs: fsys,

View File

@@ -675,6 +675,155 @@ var _ = Describe("Scanner", Ordered, func() {
})
})
Describe("Interrupted scan resumption", func() {
var fsys storagetest.FakeFS
var help func(...map[string]any) *fstest.MapFile
BeforeEach(func() {
help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965})
fsys = createFS(fstest.MapFS{
"The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")),
"The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")),
})
})
simulateInterruptedScan := func(fullScan bool) {
// Call ScanBegin to properly set LastScanStartedAt and FullScanInProgress
// This simulates what would happen if a scan was interrupted (ScanBegin called but ScanEnd not)
Expect(ds.Library(ctx).ScanBegin(lib.ID, fullScan)).To(Succeed())
// Verify the update was persisted
reloaded, err := ds.Library(ctx).Get(lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.LastScanStartedAt).ToNot(BeZero())
Expect(reloaded.FullScanInProgress).To(Equal(fullScan))
}
Context("when a quick scan is interrupted and resumed with a full scan request", func() {
BeforeEach(func() {
// First, complete a full scan to populate the database
Expect(runScanner(ctx, true)).To(Succeed())
// Verify files were imported
mfs, err := ds.MediaFile(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
// Now simulate an interrupted quick scan
// (LastScanStartedAt is set, FullScanInProgress is false)
simulateInterruptedScan(false)
})
It("should rescan all folders when resumed as full scan", func() {
// Update a tag without changing the folder hash by preserving the original modtime.
// In a quick scan, this wouldn't be detected because the folder hash hasn't changed.
// But in a full scan, all files should be re-read regardless of hash.
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "updated comment"}, origModTime)
// Resume with a full scan - this should process all folders
// even though folder hashes haven't changed
Expect(runScanner(ctx, true)).To(Succeed())
// Verify the comment was updated (which means the folder was processed and file re-imported)
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Help!"},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1))
Expect(mfs[0].Comment).To(Equal("updated comment"))
})
})
Context("when a full scan is interrupted and resumed with a quick scan request", func() {
BeforeEach(func() {
// First, complete a full scan to populate the database
Expect(runScanner(ctx, true)).To(Succeed())
// Verify files were imported
mfs, err := ds.MediaFile(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
// Now simulate an interrupted full scan
// (LastScanStartedAt is set, FullScanInProgress is true)
simulateInterruptedScan(true)
})
It("should continue as full scan even when quick scan is requested", func() {
// Update a tag without changing the folder hash by preserving the original modtime.
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "full scan comment"}, origModTime)
// Request a quick scan - but because a full scan was in progress,
// it should continue as a full scan
Expect(runScanner(ctx, false)).To(Succeed())
// Verify the comment was updated (folder was processed despite unchanged hash)
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Help!"},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1))
Expect(mfs[0].Comment).To(Equal("full scan comment"))
})
})
Context("when no scan was in progress", func() {
BeforeEach(func() {
// First, complete a full scan to populate the database
Expect(runScanner(ctx, true)).To(Succeed())
// Verify files were imported
mfs, err := ds.MediaFile(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
// Library should have LastScanStartedAt cleared after successful scan
updatedLib, err := ds.Library(ctx).Get(lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updatedLib.LastScanStartedAt).To(BeZero())
Expect(updatedLib.FullScanInProgress).To(BeFalse())
})
It("should respect the full scan flag for new scans", func() {
// Update a tag without changing the folder hash by preserving the original modtime.
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "new full scan"}, origModTime)
// Start a new full scan
Expect(runScanner(ctx, true)).To(Succeed())
// Verify the comment was updated
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Help!"},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1))
Expect(mfs[0].Comment).To(Equal("new full scan"))
})
It("should not rescan unchanged folders during quick scan", func() {
// Update a tag without changing the folder hash by preserving the original modtime.
// This simulates editing tags in a file (e.g., with a tag editor) without modifying its timestamp.
// In a quick scan, this should NOT be detected because the folder hash remains unchanged.
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "should not appear"}, origModTime)
// Do a quick scan - unchanged folders should be skipped
Expect(runScanner(ctx, false)).To(Succeed())
// Verify the comment was NOT updated (folder was skipped)
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Help!"},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1))
Expect(mfs[0].Comment).To(BeEmpty())
})
})
})
Describe("RefreshStats", func() {
var refreshStatsCalls []bool
var fsys storagetest.FakeFS

View File

@@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject()
}
func UsernameFromReverseProxyHeader(r *http.Request) string {
if conf.Server.ReverseProxyWhitelist == "" {
func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
@@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
@@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
username = UsernameFromReverseProxyHeader(r)
username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}

View File

@@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = ""
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
})
It("sets auth data if IPv4 matches whitelist", func() {
@@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = ""
conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req)
@@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
@@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("makes the first user an admin", func() {

View File

@@ -107,7 +107,7 @@ func secureMiddleware() func(http.Handler) http.Handler {
FrameDeny: true,
ReferrerPolicy: "same-origin",
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
CustomFrameOptionsValue: conf.Server.HTTPSecurityHeaders.CustomFrameOptionsValue,
CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions,
//ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'",
})
return sec.Handler
@@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ReverseProxyWhitelist != "" {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,

View File

@@ -118,11 +118,7 @@ func (api *Router) routes() http.Handler {
hr(r, "getAlbumList2", api.GetAlbumList2)
h(r, "getStarred", api.GetStarred)
h(r, "getStarred2", api.GetStarred2)
if conf.Server.EnableNowPlaying {
h(r, "getNowPlaying", api.GetNowPlaying)
} else {
h501(r, "getNowPlaying")
}
h(r, "getNowPlaying", api.GetNowPlaying)
h(r, "getRandomSongs", api.GetRandomSongs)
h(r, "getSongsByGenre", api.GetSongsByGenre)
})

View File

@@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true
}
return server.UsernameFromReverseProxyHeader(r), false
return server.UsernameFromExtAuthHeader(r), false
}
func checkRequiredParameters(next http.Handler) http.Handler {

View File

@@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
})
It("passes when all required params are available (reverse-proxy case)", func() {
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
@@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
conf.Server.ReverseProxyUserHeader = "Remote-User"
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {

View File

@@ -2,11 +2,13 @@ package subsonic
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -35,7 +37,13 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
if !ok {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
username, err := req.Params(r).String("username")
if err != nil {
return nil, err
}
if !strings.EqualFold(username, loggedUser.UserName) {
return nil, newError(responses.ErrorAuthorizationFail)
}
response := newResponse()
user := buildUserResponse(loggedUser)
response.User = &user

View File

@@ -1,7 +1,7 @@
package subsonic
import (
"context"
"errors"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
@@ -43,8 +43,8 @@ var _ = Describe("Users", func() {
}
// Create request with user in context
req := httptest.NewRequest("GET", "/rest/getUser", nil)
ctx := request.WithUser(context.Background(), testUser)
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
ctx := request.WithUser(GinkgoT().Context(), testUser)
req = req.WithContext(ctx)
userResponse, err1 := router.GetUser(req)
@@ -116,4 +116,60 @@ var _ = Describe("Users", func() {
Expect(response.Folder).To(ContainElements(int32(1), int32(2), int32(5)))
})
})
Describe("GetUser authorization", func() {
It("should allow user to request their own information", func() {
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
ctx := request.WithUser(GinkgoT().Context(), testUser)
req = req.WithContext(ctx)
response, err := router.GetUser(req)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.User).ToNot(BeNil())
Expect(response.User.Username).To(Equal("testuser"))
})
It("should deny user from requesting another user's information", func() {
req := httptest.NewRequest("GET", "/rest/getUser?username=anotheruser", nil)
ctx := request.WithUser(GinkgoT().Context(), testUser)
req = req.WithContext(ctx)
response, err := router.GetUser(req)
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
})
It("should return error when username parameter is missing", func() {
req := httptest.NewRequest("GET", "/rest/getUser", nil)
ctx := request.WithUser(GinkgoT().Context(), testUser)
req = req.WithContext(ctx)
response, err := router.GetUser(req)
Expect(err).To(MatchError("missing parameter: 'username'"))
Expect(response).To(BeNil())
})
It("should return error when user context is missing", func() {
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
response, err := router.GetUser(req)
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
})
})

View File

@@ -25,6 +25,7 @@ type MockDataStore struct {
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
@@ -208,12 +209,23 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe
if db.RealDS != nil {
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
} else {
db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo()
db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{}
}
}
return db.MockedScrobbleBuffer
}
func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
if db.MockedScrobble == nil {
if db.RealDS != nil {
db.MockedScrobble = db.RealDS.Scrobble(ctx)
} else {
db.MockedScrobble = &MockScrobbleRepo{ctx: ctx}
}
}
return db.MockedScrobble
}
func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
if db.MockedRadio == nil {
if db.RealDS != nil {

View File

@@ -0,0 +1,24 @@
package tests
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type MockScrobbleRepo struct {
RecordedScrobbles []model.Scrobble
ctx context.Context
}
func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error {
user, _ := request.UserFrom(m.ctx)
m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{
MediaFileID: fileID,
UserID: user.ID,
SubmissionTime: submissionTime,
})
return nil
}

7
ui/package-lock.json generated
View File

@@ -7173,10 +7173,11 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},

View File

@@ -1,12 +1,15 @@
import React from 'react'
import { TopToolbar, ExportButton } from 'react-admin'
import { TopToolbar, ExportButton, useListContext } from 'react-admin'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
const MissingListActions = (props) => (
<TopToolbar {...props}>
<ExportButton />
<DeleteMissingFilesButton deleteAll />
</TopToolbar>
)
const MissingListActions = (props) => {
const { total } = useListContext()
return (
<TopToolbar {...props}>
<ExportButton maxResults={total} />
<DeleteMissingFilesButton deleteAll />
</TopToolbar>
)
}
export default MissingListActions

View File

@@ -16,6 +16,7 @@ import {
usePermissions,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import {
DurationField,
@@ -28,6 +29,12 @@ import {
import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton'
const useStyles = makeStyles((theme) => ({
button: {
color: theme.palette.type === 'dark' ? 'white' : undefined,
},
}))
const PlaylistFilter = (props) => {
const { permissions } = usePermissions()
return (
@@ -112,13 +119,24 @@ const ToggleAutoImport = ({ resource, source }) => {
) : null
}
const PlaylistListBulkActions = (props) => (
<>
<ChangePublicStatusButton public={true} {...props} />
<ChangePublicStatusButton public={false} {...props} />
<BulkDeleteButton {...props} />
</>
)
const PlaylistListBulkActions = (props) => {
const classes = useStyles()
return (
<>
<ChangePublicStatusButton
public={true}
{...props}
className={classes.button}
/>
<ChangePublicStatusButton
public={false}
{...props}
className={classes.button}
/>
<BulkDeleteButton {...props} className={classes.button} />
</>
)
}
const PlaylistList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))

View File

@@ -1,4 +1,10 @@
const stylesheet = `
.react-jinke-music-player-main .music-player-panel svg {
color: #eee
}
.react-jinke-music-player-main .music-player-panel button:disabled svg {
opacity: 0.3
}
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
color: #D60017
}
@@ -27,7 +33,6 @@ const stylesheet = `
.react-jinke-music-player-main .audio-item.playing .player-singer {
color: #ff4e6b !important
}
.react-jinke-music-player-main .lyric-btn,
.react-jinke-music-player-main .lyric-btn-active svg{
color: #ff4e6b !important
}

View File

@@ -194,7 +194,12 @@ export default {
},
RaDeleteWithConfirmButton: {
deleteButton: {
color: 'unset',
color: '#fff',
},
},
RaBulkDeleteWithUndoButton: {
deleteButton: {
color: '#fff',
},
},
RaPaginationActions: {

View File

@@ -42,6 +42,12 @@ const useCurrentTheme = () => {
document.head.removeChild(style)
}
}
// Set body background color to match theme (fixes white background on pull-to-refresh)
const isDark = theme.palette?.type === 'dark'
const bgColor =
theme.palette?.background?.default || (isDark ? '#303030' : '#fafafa')
document.body.style.backgroundColor = bgColor
}, [theme])
return theme

View File

@@ -15,6 +15,10 @@ function createMatchMedia(theme) {
})
}
beforeEach(() => {
document.body.style.backgroundColor = ''
})
describe('useCurrentTheme', () => {
describe('with user preference theme as light', () => {
beforeAll(() => {
@@ -117,4 +121,44 @@ describe('useCurrentTheme', () => {
expect(result.current.themeName).toMatch('Spotify-ish')
})
})
describe('body background color', () => {
beforeAll(() => {
window.matchMedia = createMatchMedia('dark')
})
it('sets body background for dark theme', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}>
{children}
</Provider>
),
})
// Dark theme uses MUI default dark background
expect(document.body.style.backgroundColor).toBe('rgb(48, 48, 48)')
})
it('sets body background for light theme', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider store={createStore(themeReducer, { theme: 'LightTheme' })}>
{children}
</Provider>
),
})
// Light theme uses MUI default light background
expect(document.body.style.backgroundColor).toBe('rgb(250, 250, 250)')
})
it('sets body background for theme with custom background', () => {
renderHook(() => useCurrentTheme(), {
wrapper: ({ children }) => (
<Provider
store={createStore(themeReducer, { theme: 'SpotifyTheme' })}
>
{children}
</Provider>
),
})
// Spotify theme has explicit background.default: #121212
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
})
})
})

View File

@@ -2,11 +2,15 @@ package number
import (
"strconv"
"golang.org/x/exp/constraints"
)
func ParseInt[T constraints.Integer](s string) T {
// Integer is a constraint that permits any integer type.
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
func ParseInt[T Integer](s string) T {
r, _ := strconv.ParseInt(s, 10, 64)
return T(r)
}

View File

@@ -5,12 +5,12 @@ import (
"encoding/binary"
"math/big"
"golang.org/x/exp/constraints"
"github.com/navidrome/navidrome/utils/number"
)
// Int64N returns a random int64 between 0 and max.
// This is a reimplementation of math/rand/v2.Int64N using a cryptographically secure random number generator.
func Int64N[T constraints.Integer](max T) int64 {
func Int64N[T number.Integer](max T) int64 {
rnd, _ := rand.Int(rand.Reader, big.NewInt(int64(max)))
return rnd.Int64()
}

View File

@@ -6,9 +6,8 @@ import (
"cmp"
"io"
"iter"
"maps"
"slices"
"golang.org/x/exp/maps"
)
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
@@ -49,11 +48,9 @@ func CompactByFrequency[T comparable](list []T) []T {
counters[item]++
}
sorted := maps.Keys(counters)
slices.SortFunc(sorted, func(i, j T) int {
return slices.SortedFunc(maps.Keys(counters), func(i, j T) int {
return cmp.Compare(counters[j], counters[i])
})
return sorted
}
func MostFrequent[T comparable](list []T) T {