Compare commits

..

20 commits

Author SHA1 Message Date
Deluan Quintão
df77c462e8
feat(ui): add a Date Format setting to choose language or browser dates (#6263)
* feat(ui): add a Date Format setting to choose language or browser dates

Since #6160 dates follow the selected UI language, which is wrong for users who run the UI in one language but want their own region's date format. The Personal page now has a Date Format select with "Match language" (default, current behavior) and "Match browser". The choice is kept in localStorage and read by useDateLocale, which returns no locale in browser mode so Intl uses the browser's own.

Six screens imported react-admin's DateField directly and so ignored the language setting (playlist, radio and missing files lists, album table, song and album info). They now use the common DateField wrapper, which also reads the record from the record context.

* feat(ui): add pt-BR translations for the Date Format setting
2026-10-18 01:00:24 -07:00
Deluan Quintão
a45adbbb2e
chore: move README badge and dev image tag to navidrome/navidrome 2026-10-16 14:54:33 -07:00
Deluan Quintão
be201a2d79
ci: also publish Docker images to navidrome/navidrome on Docker Hub (#6327)
Run the Docker Hub manifest job as a matrix over the target repositories,
so the existing DOCKER_HUB_REPO (deluan/navidrome) push is unchanged and
the upstream repository also pushes the same tags to navidrome/navidrome.
Each leg fails independently, so a problem with one repository does not
block the other.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-14 07:23:26 -07:00
Deluan Quintão
30663f7d4f
fix(jellyfin): honor likes on /Rating and add POST UserData endpoints
* fix(jellyfin): honor likes on /Rating and add POST UserData endpoints

POST .../Rating only read a numeric `rating` param that upstream Jellyfin
does not define, so a request using Jellyfin's `likes` flag (or no value
at all, or a malformed one) silently cleared the user's rating. The
handler now reads `likes` like Jellyfin (true stores 5 stars, false 1
star, matching Jellyfin's 10 and 1), keeps the numeric `rating` param for
existing clients, and answers 400 without writing when the value is
missing, malformed or outside 0-10. Clearing is left to DELETE. The
current /UserItems/{itemId}/Rating spelling is now routed as well.

Adds POST /UserItems/{itemId}/UserData and the legacy
/Users/{userId}/Items/{itemId}/UserData. They write Rating, Likes and
IsFavorite with Jellyfin's precedence (Rating wins over Likes), leave
omitted or null fields untouched, and ignore play-state fields. The body
must be a single JSON object: trailing data, null, or out-of-range values
are rejected before anything is written.

Rating and UserData writes whose userId names a different user now get
403 instead of silently writing the caller's own data. Annotation
responses return the item's full UserData, including Jellyfin's derived
Likes flag (rating >= 6.5). The README compatibility notes are updated.

* refactor(jellyfin): simplify rating and UserData handlers

Moves the Jellyfin 0-10 to stars conversions (StarsFromRating,
StarsFromLikes) into the dto package next to the reverse mapping and the
Likes threshold, so both directions live together. The userId ownership
check is folded into a single applyCallerUserData helper instead of being
called by hand in three handlers, the setFavorite passthrough is dropped,
and decodeUserDataBody now writes its own 400 like the other (value, ok)
helpers.

Tests: withChiURLParam is now variadic, replacing the duplicate
withChiParams helper; the three other-user 403 specs and the
empty-object/no-event spec become table entries; and redundant specs are
removed. Behavior is unchanged.
2026-10-12 11:49:07 -07:00
Rucolastico
8e306b1f61
feat(ui): add Solarized Light and Dark themes (#6280)
* feat(ui): add Solarized Light and Dark themes

Add Light and Dark variants of Ethan Schoonover's Solarized palette, built
on the Tokyo Night theme structure so they cover the app bar, sidebar,
tables, album grid, album/playlist details, login and the player.

Text colors are one step stronger than canonical Solarized body text
(base01 on light, base1 on dark) so table text keeps WCAG AA contrast.
The Dark variant uses cyan instead of violet as the secondary accent,
since violet is too dark on base03.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rucolastico <diegoricomartell@gmail.com>

* fix(ui): improve Solarized contrast and table row hover

Address review feedback on the Solarized themes:

- Use text-specific shades for links and the login system name, keeping
  them above 4.5:1 on table surfaces and hover rows (darkened blue on
  Light, lightened cyan on Dark). Canonical blue stays for controls.
- Darken the Light theme's violet accent, which is also used as text
  (album artist), to keep it above 4.5:1.
- Give secondary buttons readable text: base3 on violet (Light) and
  base03 on cyan (Dark), instead of the body text color.
- Apply the row hover color to table cells, whose opaque background was
  hiding the row hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rucolastico <diegoricomartell@gmail.com>

* fix(ui): keep Solarized app bar title and icons readable

The app bar uses the secondary color, whose contrastText matched the
app bar background (1:1 in Light, 1.15:1 in Dark). Override the app bar's
colorSecondary text with the theme foreground (4.99:1 Light, 4.86:1 Dark),
keeping secondary.contrastText for buttons on the accent color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rucolastico <diegoricomartell@gmail.com>

---------

Signed-off-by: rucolastico <diegoricomartell@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-10-11 13:33:34 -04:00
Deluan Quintão
4ae97ab59e
Merge pull request #6313 from navidrome/investigate-6310-20261010
fix(playlists): drop unknown track ids when adding to a playlist
2026-10-11 10:05:15 -07:00
Deluan
803b2da8ee refactor(playlists): simplify #6310 tests and update check comments
Builds the removeOrphans fixture with a single INSERT instead of shifting positions in two UPDATE passes, and has the Insert test keep the playlist id instead of reading a private repository field. Two comments now say that addTracks owns the existence check as well as the library check, and that Insert closes holes left by any id keepAccessible drops. No behavior change.
2026-10-10 11:35:43 -04:00
Deluan
74e96a2bb7 fix(playlists): drop unknown track ids when adding to a playlist
Adding tracks to a playlist skipped the media_file lookup for admins and for users who can see every library, so ids matching no track were stored as orphan playlist_tracks rows and reported as added. The native API returned an inflated "added" count, and the hidden rows left gaps in the positions, so Subsonic songIndexToRemove could delete a hidden row instead of the song at that index.

keepAccessible now always checks the ids against media_file. applyLibraryFilter is already a no-op for those users, so the check only drops unknown ids. Rows stored before this fix are still removed by the existing GC removeOrphans step on the next full scan, or on any scan that finds changes.

Fixes #6310
2026-10-10 11:28:58 -04:00
Deluan Quintão
497dfe2586
Merge pull request #6300 from navidrome/docs/hosting-badges-6258
docs: render managed hosting links as proportionate badges
2026-10-09 05:45:55 -07:00
deluan
b70ba3a49e docs: use equal-size local hosting badges without stretching artwork 2026-10-08 22:19:34 -04:00
deluan
d5a2e1e813 docs: match README deployment badges to native PikaPods height 2026-10-08 22:02:28 -04:00
deluan
b4d2c3e4af docs: render hosting providers as proportionate deployment badges 2026-10-08 21:59:52 -04:00
raphmim
22ce85e5d5 Minor fixes based on AI suggestions 2026-10-08 21:59:04 -04:00
raphmim
aea60c6e99 Badges for new cloud providers 2026-10-08 21:59:04 -04:00
Deluan Quintão
b78c963931
fix(scanner): respect FollowSymlinks in watcher-triggered scans (#6293)
* fix(scanner): respect FollowSymlinks in watcher-triggered scans

With FollowSymlinks disabled, creating a folder symlink inside the library
made the watcher schedule a selective scan with the link itself as the
target. walkDirTree only checked FollowSymlinks for child entries, so it
walked the link and imported its files as duplicates (or, for links that
point outside the library, files that should never be scanned).

walkDirTree now skips any target folder whose path, or any parent folder,
is a symlink when FollowSymlinks is disabled. The skipped target stays in
lastUpdates, so rows previously imported through it are marked missing,
matching what a full scan does. localFS now implements fs.ReadLinkFS so
fs.Lstat can see symlinks instead of following them.

Fixes #6292

* test(scanner): run the #6292 symlinked target tests on Windows

Remove the SkipOnWindows guard from the symlinked target folder tests, so
the go-windows CI job covers the FollowSymlinks fix for selective scans.
2026-10-08 17:34:42 -04:00
Deluan Quintão
52135913d4
fix(artwork): don't crash the server when a playlist's tracks can't be loaded (#6267)
Playlist().Tracks returns nil when its internal Get fails (for example when the
context is canceled at shutdown), and resolvePlaylist called GetAlbumIDs on it,
panicking with a nil pointer dereference. The artwork drain runs on a bare
goroutine, so the panic killed the whole server.

resolvePlaylist now returns an error when Tracks is nil, and the worker recovers
panics per item: it logs the panic with the item details and stack, and marks
the item as a failed attempt so the rest of the batch still runs.

Fixes #6266
2026-10-06 07:57:07 -07:00
Deluan Quintão
caa2f8a0c0
fix(ui): make playlist toggle switches visible in all themes (#6277)
* fix(ui): make playlist toggle switches visible in all themes

The Public and Auto-import switches in the playlist list did not set a
color, so Material-UI used the theme's secondary color. Many themes use
secondary as a surface color close to the table background, which made
checked switches nearly invisible (Catppuccin, Rosé Pine, Monokai,
Moonbase and others).

Set color="primary" on the playlist switch, like every other switch in
the app, and make primary the default MuiSwitch color in useCurrentTheme
so future switches cannot regress. Fixes #6272.

* refactor(ui): drop secondary switch overrides from themes

Dracula, Gruvbox Dark, Tokyo Night and Tokyo Night Light styled checked
MuiSwitch colorSecondary to work around the same invisible-switch problem
(Gruvbox in #5064). With primary as the default switch color and every
switch in the app using it, no switch renders with colorSecondary anymore,
so these overrides are dead code.
2026-10-06 09:50:11 -04:00
Deluan Quintão
95f67d2c4e
fix(share): reuse cached transcodes for share streams and zip downloads (#6262)
* fix(share): reuse cached transcodes when streaming from share links

Public share streams built the stream request with only the share's format
and bit rate, leaving sample rate, bit depth and channels at zero. Regular
playback resolves those through the transcode decider (e.g. 48000 Hz for
Opus), and they are part of the transcoding cache key, so a track already
transcoded during normal playback was transcoded again into a separate,
identical cache entry when played through a share link.

The public router now resolves share stream requests with the same
TranscodeDecider.ResolveRequest used by the Subsonic stream endpoint, so
both paths produce the same request and share cache entries.

Fixes #6261

* fix(archiver): reuse cached transcodes when zipping downloads

Zip downloads (album, artist, playlist and share) built the stream request
with only the format and bit rate, leaving sample rate, bit depth and
channels at zero. Those are part of the transcoding cache key, so a track
already transcoded for playback was transcoded again into a separate cache
entry when downloaded in a zip, and vice versa.

The archiver now resolves each request with TranscodeDecider.ResolveRequest,
the same as single-song downloads and streams. This also applies the
decider's defaults, so a zip requested without a bit rate uses the target
format's default bit rate instead of leaving it to ffmpeg.

* fix(archiver): name zip entries after the resolved transcoding format

The transcode decider can pick a different format than the one requested
(for example a player's forced transcoding, or a fallback to the default
downsampling format when the requested one can't be produced). Zip entry
names and the playlist M3U were still built from the requested format, so
an entry could end in .mp3 or .flac while holding Opus data.

Each track's request is now resolved before its entry name is built, and
the name uses the resolved format.
2026-10-03 08:58:54 -07:00
Deluan Quintão
758e64c999
feat(scanner): per-library PID configuration (#6252)
* feat(model): add per-library PID config columns

* refactor(metadata): pass PID config to ToMediaFile and add spec validation

* feat(scanner): rescan only libraries whose PID config changed

* feat(server): validate library PID config and rescan on change

* feat(ui): edit per-library PID config

* fix(ui): label the PID mode selects

* fix: tighten per-library PID rescan edge cases

An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys.

* refactor(metadata): pass the library to ToMediaFile

ToMediaFile and core.Inspect took the library ID and its PID config as
separate arguments, so a caller could mix values from two libraries. They
now take the model.Library and resolve the effective PID config from it.

* chore: tidy per-library PID comments, PropTypes and migration

Trim comments that restated the code, add PropTypes to the new UI
components, and recreate the migration with make migration-sql.

* fix(ui): show the PID spec help under its input

* feat(cmd): make inspect use the file's library PID config

inspect always used the global PID config, so it showed different IDs than
the scanner for files in a library with an override. It now finds the
file's library in the DB and uses its effective config, falling back to
the global config when there is no DB or the file is outside every
library. It never creates a DB. The library path matcher moves from
core/playlists to model so both can use it.

* refactor: simplify per-library PID code

Share the DB-file check between CLI commands, move ErrAlreadyScanning to
model so core no longer imports scanner, read the libraries once for
insights, and let ValidatePIDSpec accept an empty spec and look tags up
directly. In the scanner, use FullScanInProgress instead of a second
flag, and skip recomputing album IDs when the album spec did not change.
In the UI, share the PID inputs between Create and Edit, and use docsUrl.

* feat(ui): add section titles to Library Create and pre-fill Custom PID specs

Custom now starts from the global spec, so admins edit a working spec
instead of typing one from scratch.

* fix(inspect): map files with the library-relative path the scanner uses

Inspect gave metadata the file's directory as typed, so folder-based PIDs
never matched the DB. It now uses the path relative to the library root,
through the scanner's helper, which moves to model.

* fix(scanner): say when a PID rescan only covers target folders

* fix: reject tag aliases in album PID specs and match root libraries

Tags are stored under canonical names, so an alias in a spec always reads
as empty. In an album spec that gives every album the same ID, so album
specs now require the tag name. Track specs keep accepting aliases, since
the default one uses them. LibraryMatcher now matches paths under a
library at the filesystem root.

* refactor(model): move the tag alias lookup to tag_mappings.go

* test: run the library matcher and inspect tests on Windows

Build test paths with filepath instead of Unix literals, so they use the
OS separator like filepath.Abs output, and drop the Windows skips.

* feat(ui): add pt-BR translations for per-library PID settings
2026-10-02 05:05:32 -04:00
Deluan Quintão
0e1893530b
fix(ui): don't crash the playlist list when rows lose their record (#6250)
* fix(ui): don't crash playlist list rows that lost their record

react-admin 3 evicts records fetched more than 10 minutes ago whenever
another getList for the same resource completes, but the list keeps its
cached ids. The Datagrid then renders those rows with an undefined record,
and the Public and Auto-import switches crashed reading record.id. This
happened when the playlist list was left open and the sidebar or the add
to playlist dialog reloaded a smaller set of playlists.

Both switches now render nothing when the row has no record; the next list
refresh fills the row in again.

* refactor(ui): merge playlist list toggles into one ToggleField

The Public and Auto-import switches were copies that differed only in the
field they flip. ToggleField now flips its source field, and
ToggleAutoImport just shows it for playlists that have a file path. The
tests render inside TestContext, so they use react-admin's real hooks
instead of mocks.
2026-09-29 13:23:36 -04:00
198 changed files with 4027 additions and 7861 deletions

View file

@ -519,13 +519,18 @@ jobs:
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }} docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub: push-manifest-dockerhub:
name: Push to Docker Hub name: Push to Docker Hub (${{ matrix.repository }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
needs: [build, check-push-enabled] needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true continue-on-error: true
strategy:
fail-fast: false
matrix:
# Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository.
repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@ -541,7 +546,7 @@ jobs:
id: docker id: docker
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }} hub_repository: ${{ matrix.repository }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@ -558,7 +563,7 @@ jobs:
- name: Inspect image in Docker Hub - name: Inspect image in Docker Hub
run: | run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }} docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
cleanup-digests: cleanup-digests:
name: Cleanup digest artifacts name: Cleanup digest artifacts

View file

@ -18,7 +18,7 @@ endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386 SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS) PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop DOCKER_TAG ?= navidrome/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.14.0 GOLANGCI_LINT_VERSION ?= v2.14.0
VACUUM_VERSION ?= v0.30.6 VACUUM_VERSION ?= v0.30.6
@ -223,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--output "./binaries" --target binary . --output "./binaries" --target binary .
.PHONY: docker-build .PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true

View file

@ -5,7 +5,7 @@
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) [![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) [![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) [![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome) [![Docker Pulls](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.navidrome.org%2Fbadges%2Fcontainer-downloads.json&logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/navidrome/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting ## Cloud Hosting
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
A share of the revenue helps fund the development of Navidrome at no additional cost for you. <a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
<br>
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
<br>
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features ## Features

View file

@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo} info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info) metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(1, "folderID")) return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
} }
BeforeEach(func() { BeforeEach(func() {

View file

@ -36,14 +36,6 @@ rules:
- sharing - sharing
- radio - radio
- admin - admin
- password
nd-operation-security-required:
description: Every operation declares security explicitly (use [] for public operations).
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: security
function: defined
nd-operation-stability-level-required: nd-operation-stability-level-required:
description: Every operation declares its stability level, which the breaking-change gate relies on. description: Every operation declares its stability level, which the breaking-change gate relies on.
severity: error severity: error

View file

@ -3,7 +3,7 @@
"info": { "info": {
"title": "Navidrome API", "title": "Navidrome API",
"version": "1.0.0", "version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n", "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
"license": { "license": {
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html" "url": "https://www.gnu.org/licenses/gpl-3.0.html"
@ -18,10 +18,6 @@
{ {
"name": "server", "name": "server",
"description": "Server discovery and the published OpenAPI document." "description": "Server discovery and the published OpenAPI document."
},
{
"name": "auth",
"description": "Grants and login methods."
} }
], ],
"paths": { "paths": {
@ -33,9 +29,8 @@
"tags": [ "tags": [
"server" "server"
], ],
"security": [],
"summary": "Describe the server", "summary": "Describe the server",
"description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n", "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
"responses": { "responses": {
"200": { "200": {
"description": "Server description.", "description": "Server description.",
@ -53,41 +48,6 @@
} }
} }
}, },
"/capabilities": {
"get": {
"operationId": "getCapabilities",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "List implemented capability modules",
"description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Implemented modules.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capabilities"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.json": { "/openapi.json": {
"get": { "get": {
"operationId": "getOpenAPISpecJSON", "operationId": "getOpenAPISpecJSON",
@ -96,7 +56,6 @@
"tags": [ "tags": [
"server" "server"
], ],
"security": [],
"summary": "Get the OpenAPI document (JSON)", "summary": "Get the OpenAPI document (JSON)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": { "responses": {
@ -122,56 +81,6 @@
} }
} }
}, },
"/auth/grants": {
"get": {
"operationId": "listGrants",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "List my grants",
"description": "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed.",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/offset"
},
{
"$ref": "#/components/parameters/limit"
}
],
"responses": {
"200": {
"description": "A page of grants.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantList"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.yaml": { "/openapi.yaml": {
"get": { "get": {
"operationId": "getOpenAPISpecYAML", "operationId": "getOpenAPISpecYAML",
@ -180,7 +89,6 @@
"tags": [ "tags": [
"server" "server"
], ],
"security": [],
"summary": "Get the OpenAPI document (YAML)", "summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": { "responses": {
@ -205,262 +113,6 @@
} }
} }
} }
},
"/auth/grants/{id}": {
"delete": {
"operationId": "revokeGrant",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Revoke one of my grants",
"description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Grant id.",
"schema": {
"type": "string",
"maxLength": 64
}
}
],
"responses": {
"204": {
"description": "Revoked."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/logout": {
"post": {
"operationId": "logout",
"x-module": "core",
"x-scope": "read",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Log out",
"description": "Revokes the grant that made this request.",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Logged out.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LogoutResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/login": {
"post": {
"operationId": "login",
"x-module": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Log in with a password",
"description": "Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.",
"security": [],
"requestBody": {
"description": "The credentials and a description of the client.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsRequest"
}
}
}
},
"responses": {
"200": {
"description": "The new grant.",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControlNoStore"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantCreated"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/setup": {
"post": {
"operationId": "setupFirstAdmin",
"x-module": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Create the first admin",
"description": "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409.",
"security": [],
"requestBody": {
"description": "The credentials and a description of the client.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CredentialsRequest"
}
}
}
},
"responses": {
"201": {
"description": "The admin was created.",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControlNoStore"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrantCreated"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/auth/password": {
"post": {
"operationId": "changePassword",
"x-module": "password",
"x-scope": "password",
"x-stability-level": "alpha",
"tags": [
"auth"
],
"summary": "Change my password",
"description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server.",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"description": "The current and the new password.",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordChangeRequest"
}
}
}
},
"responses": {
"204": {
"description": "Password changed."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
} }
}, },
"components": { "components": {
@ -468,7 +120,8 @@
"bearerAuth": { "bearerAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." "bearerFormat": "JWT",
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
} }
}, },
"schemas": { "schemas": {
@ -500,23 +153,17 @@
"description": "True until the first admin user has been created." "description": "True until the first admin user has been created."
}, },
"loginMethods": { "loginMethods": {
"$ref": "#/components/schemas/LoginMethods" "type": "array",
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
"items": {
"type": "string",
"enum": [
"password"
]
}
} }
} }
}, },
"LoginMethods": {
"type": "object",
"description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n",
"properties": {
"password": {
"$ref": "#/components/schemas/PasswordLoginMethod"
}
}
},
"PasswordLoginMethod": {
"type": "object",
"description": "Username and password login (`POST /auth/login`). No settings yet."
},
"Problem": { "Problem": {
"type": "object", "type": "object",
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
@ -540,7 +187,7 @@
}, },
"detail": { "detail": {
"type": "string", "type": "string",
"description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients." "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
}, },
"code": { "code": {
"type": "string", "type": "string",
@ -549,21 +196,12 @@
"validation", "validation",
"unauthorized", "unauthorized",
"forbidden", "forbidden",
"insufficient_scope",
"not_found", "not_found",
"method_not_allowed", "method_not_allowed",
"setup_complete",
"password_managed_externally",
"payload_too_large",
"rate_limited",
"unavailable", "unavailable",
"internal" "internal"
] ]
}, },
"referenceId": {
"type": "string",
"description": "Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request."
},
"errors": { "errors": {
"type": "array", "type": "array",
"description": "Per-field failures. Present only when `code` is `validation`.", "description": "Per-field failures. Present only when `code` is `validation`.",
@ -590,310 +228,6 @@
"description": "Why the value was rejected." "description": "Why the value was rejected."
} }
} }
},
"Capabilities": {
"type": "object",
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n",
"properties": {
"core": {
"$ref": "#/components/schemas/CoreCapability"
},
"password": {
"$ref": "#/components/schemas/PasswordCapability"
}
}
},
"CoreCapability": {
"type": "object",
"description": "The mandatory core module.",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"PasswordCapability": {
"type": "object",
"description": "The password login module (login, first-admin setup, password change).",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"GrantList": {
"type": "object",
"description": "A page of the caller's grants.",
"required": [
"items",
"total",
"offset",
"limit"
],
"properties": {
"items": {
"type": "array",
"description": "Grants on this page, by last use, most recent first; never-used grants last.",
"items": {
"$ref": "#/components/schemas/Grant"
}
},
"total": {
"type": "integer",
"description": "Total number of grants."
},
"offset": {
"type": "integer",
"description": "Zero-based index of the first returned item."
},
"limit": {
"type": "integer",
"description": "Maximum number of items in this page."
}
}
},
"LogoutResponse": {
"type": "object",
"description": "Result of a logout.",
"required": [
"logoutUrl"
],
"properties": {
"logoutUrl": {
"type": "string",
"nullable": true,
"description": "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
}
}
},
"CredentialsRequest": {
"type": "object",
"description": "Username, password and client description for a login or first-admin setup.",
"required": [
"username",
"password",
"client"
],
"properties": {
"username": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"description": "Login name."
},
"password": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "Password."
},
"client": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Name of the client app."
},
"clientVersion": {
"type": "string",
"maxLength": 32,
"description": "Version of the client app."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "Label for this grant. Defaults to `client`."
},
"scopes": {
"type": "array",
"maxItems": 32,
"description": "Scopes the grant may hold. Omit for `all`.",
"items": {
"$ref": "#/components/schemas/ScopeRequest"
}
}
}
},
"GrantCreated": {
"type": "object",
"description": "Returned by every login method. The secret is shown only here; store it and never parse it.",
"required": [
"secret",
"grant",
"user"
],
"properties": {
"secret": {
"type": "string",
"maxLength": 512,
"description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`."
},
"grant": {
"description": "The new grant.",
"allOf": [
{
"$ref": "#/components/schemas/Grant"
}
]
},
"user": {
"description": "The user the grant belongs to.",
"allOf": [
{
"$ref": "#/components/schemas/AuthUser"
}
]
}
}
},
"PasswordChangeRequest": {
"type": "object",
"description": "Change the caller's own password.",
"required": [
"currentPassword",
"newPassword"
],
"properties": {
"currentPassword": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "The current password."
},
"newPassword": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"description": "The new password."
},
"revokeOtherGrants": {
"type": "boolean",
"default": true,
"description": "Revoke every other grant of the user. The calling grant always survives. Default true."
}
}
},
"Grant": {
"type": "object",
"description": "A long-lived grant held by one client of one user.",
"required": [
"id",
"name",
"client",
"clientVersion",
"scopes",
"provider",
"createdAt",
"lastUsedAt",
"lastUsedIp",
"current"
],
"properties": {
"id": {
"type": "string",
"description": "Grant id."
},
"name": {
"type": "string",
"description": "Label shown to the user."
},
"client": {
"type": "string",
"description": "Name of the client app that holds the grant."
},
"clientVersion": {
"type": "string",
"nullable": true,
"description": "Version of the client app, when it sent one."
},
"scopes": {
"type": "array",
"description": "Scopes this grant carries.",
"items": {
"$ref": "#/components/schemas/Scope"
}
},
"provider": {
"type": "string",
"description": "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "When the grant was created."
},
"lastUsedAt": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "When the grant was last used, at a coarse granularity. Null until first use."
},
"lastUsedIp": {
"type": "string",
"nullable": true,
"description": "Client IP of the last use. Null until first use."
},
"current": {
"type": "boolean",
"description": "True for the grant that made this request."
}
}
},
"Scope": {
"type": "string",
"description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n",
"enum": [
"all",
"read",
"password"
]
},
"ScopeRequest": {
"type": "string",
"description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.",
"pattern": "^[a-z][a-z-]*(:write)?$",
"maxLength": 64
},
"AuthUser": {
"type": "object",
"description": "The user a grant belongs to.",
"required": [
"id",
"userName",
"name",
"isAdmin",
"passwordChangeable"
],
"properties": {
"id": {
"type": "string",
"description": "User id."
},
"userName": {
"type": "string",
"description": "Login name."
},
"name": {
"type": "string",
"description": "Display name."
},
"isAdmin": {
"type": "boolean",
"description": "Whether the user is an administrator."
},
"passwordChangeable": {
"type": "boolean",
"description": "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
}
}
} }
}, },
"responses": { "responses": {
@ -907,21 +241,6 @@
} }
} }
}, },
"Unauthorized": {
"description": "Missing, invalid, or expired credentials.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotModified": { "NotModified": {
"description": "Not modified.", "description": "Not modified.",
"headers": { "headers": {
@ -929,127 +248,14 @@
"$ref": "#/components/headers/ETag" "$ref": "#/components/headers/ETag"
} }
} }
},
"BadRequest": {
"description": "The request is malformed or fails validation.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"Forbidden": {
"description": "The caller is authenticated but not allowed to do this.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotFound": {
"description": "No such resource or endpoint.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"PayloadTooLarge": {
"description": "The request body is too large (`payload_too_large`).",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"TooManyRequests": {
"description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.",
"headers": {
"Retry-After": {
"description": "Seconds to wait before retrying.",
"schema": {
"type": "integer"
}
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"Conflict": {
"description": "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
}
},
"parameters": {
"offset": {
"name": "offset",
"in": "query",
"description": "Zero-based index of the first item to return.",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
},
"limit": {
"name": "limit",
"in": "query",
"description": "Maximum number of items to return.",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"default": 100
}
} }
}, },
"headers": { "headers": {
"WWWAuthenticate": {
"description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.",
"schema": {
"type": "string"
}
},
"ETag": { "ETag": {
"description": "Entity tag for `If-None-Match` revalidation.", "description": "Entity tag for `If-None-Match` revalidation.",
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
"CacheControlNoStore": {
"description": "Always `no-store`, because the response carries a secret.",
"schema": {
"type": "string",
"enum": [
"no-store"
]
}
} }
} }
} }

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0 version: 1.0.0
description: | description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /capabilities` and never sniff versions. capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing. accept values they do not recognise instead of failing.
@ -15,10 +15,6 @@ info:
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header. in its `Allow` header.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license: license:
name: GPL-3.0 name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html url: https://www.gnu.org/licenses/gpl-3.0.html
@ -27,8 +23,6 @@ servers:
tags: tags:
- name: server - name: server
description: Server discovery and the published OpenAPI document. description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants and login methods.
paths: paths:
/server: /server:
get: get:
@ -36,11 +30,11 @@ paths:
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Describe the server summary: Describe the server
description: | description: |
Returns the public server description. No authentication required. Returns the public server description. No authentication required.
Capability modules are listed by `GET /capabilities`. Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses: responses:
'200': '200':
description: Server description. description: Server description.
@ -50,30 +44,12 @@ paths:
$ref: '#/components/schemas/ServerInfo' $ref: '#/components/schemas/ServerInfo'
'500': '500':
$ref: '#/components/responses/InternalError' $ref: '#/components/responses/InternalError'
/capabilities:
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: '#/components/schemas/Capabilities'}
'401': {$ref: '#/components/responses/Unauthorized'}
'500': {$ref: '#/components/responses/InternalError'}
/openapi.json: /openapi.json:
get: get:
operationId: getOpenAPISpecJSON operationId: getOpenAPISpecJSON
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Get the OpenAPI document (JSON) summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses: responses:
@ -89,41 +65,12 @@ paths:
description: OpenAPI 3.0 document. description: OpenAPI 3.0 document.
'304': '304':
$ref: '#/components/responses/NotModified' $ref: '#/components/responses/NotModified'
/auth/grants:
get:
operationId: listGrants
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: List my grants
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
security: [{bearerAuth: []}]
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: A page of grants.
content:
application/json:
schema:
$ref: '#/components/schemas/GrantList'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/openapi.yaml: /openapi.yaml:
get: get:
operationId: getOpenAPISpecYAML operationId: getOpenAPISpecYAML
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Get the OpenAPI document (YAML) summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses: responses:
@ -139,172 +86,13 @@ paths:
description: OpenAPI 3.0 document. description: OpenAPI 3.0 document.
'304': '304':
$ref: '#/components/responses/NotModified' $ref: '#/components/responses/NotModified'
/auth/grants/{id}:
delete:
operationId: revokeGrant
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Revoke one of my grants
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id
in: path
required: true
description: Grant id.
schema:
type: string
maxLength: 64
responses:
'204':
description: Revoked.
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/auth/logout:
post:
operationId: logout
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Log out
description: Revokes the grant that made this request.
security: [{bearerAuth: []}]
responses:
'200':
description: Logged out.
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/auth/login:
post:
operationId: login
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Log in with a password
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialsRequest'
responses:
'200':
description: The new grant.
headers:
Cache-Control:
$ref: '#/components/headers/CacheControlNoStore'
content:
application/json:
schema:
$ref: '#/components/schemas/GrantCreated'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
/auth/setup:
post:
operationId: setupFirstAdmin
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Create the first admin
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialsRequest'
responses:
'201':
description: The admin was created.
headers:
Cache-Control:
$ref: '#/components/headers/CacheControlNoStore'
content:
application/json:
schema:
$ref: '#/components/schemas/GrantCreated'
'400':
$ref: '#/components/responses/BadRequest'
'409':
$ref: '#/components/responses/Conflict'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
/auth/password:
post:
operationId: changePassword
x-module: password
x-scope: password
x-stability-level: alpha
tags: [auth]
summary: Change my password
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
security: [{bearerAuth: []}]
requestBody:
description: The current and the new password.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChangeRequest'
responses:
'204':
description: Password changed.
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
scheme: bearer scheme: bearer
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
schemas: schemas:
ServerInfo: ServerInfo:
type: object type: object
@ -329,19 +117,12 @@ components:
type: boolean type: boolean
description: True until the first admin user has been created. description: True until the first admin user has been created.
loginMethods: loginMethods:
$ref: '#/components/schemas/LoginMethods' type: array
LoginMethods: description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
type: object items:
description: | type: string
Login methods this server accepts, keyed by method. A missing key means the method is not offered. enum:
Keys are optional on purpose: discovery is read by clients of any version against servers of any - password
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: '#/components/schemas/PasswordLoginMethod'
PasswordLoginMethod:
type: object
description: Username and password login (`POST /auth/login`). No settings yet.
Problem: Problem:
type: object type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response. description: RFC 9457 problem details, returned for every 4xx and 5xx response.
@ -364,7 +145,7 @@ components:
description: HTTP status code of this response. description: HTTP status code of this response.
detail: detail:
type: string type: string
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code: code:
type: string type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added. description: Machine-readable error code, and the value clients switch on. New codes may be added.
@ -372,18 +153,10 @@ components:
- validation - validation
- unauthorized - unauthorized
- forbidden - forbidden
- insufficient_scope
- not_found - not_found
- method_not_allowed - method_not_allowed
- setup_complete
- password_managed_externally
- payload_too_large
- rate_limited
- unavailable - unavailable
- internal - internal
referenceId:
type: string
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
errors: errors:
type: array type: array
description: Per-field failures. Present only when `code` is `validation`. description: Per-field failures. Present only when `code` is `validation`.
@ -402,238 +175,6 @@ components:
message: message:
type: string type: string
description: Why the value was rejected. description: Why the value was rejected.
Capabilities:
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling grant may use.
properties:
core:
$ref: '#/components/schemas/CoreCapability'
password:
$ref: '#/components/schemas/PasswordCapability'
CoreCapability:
type: object
description: The mandatory core module.
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
PasswordCapability:
type: object
description: The password login module (login, first-admin setup, password change).
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
GrantList:
type: object
description: "A page of the caller's grants."
required:
- items
- total
- offset
- limit
properties:
items:
type: array
description: "Grants on this page, by last use, most recent first; never-used grants last."
items:
$ref: '#/components/schemas/Grant'
total:
type: integer
description: Total number of grants.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.
LogoutResponse:
type: object
description: Result of a logout.
required:
- logoutUrl
properties:
logoutUrl:
type: string
nullable: true
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
CredentialsRequest:
type: object
description: "Username, password and client description for a login or first-admin setup."
required:
- username
- password
- client
properties:
username:
type: string
minLength: 1
maxLength: 255
description: Login name.
password:
type: string
minLength: 1
maxLength: 1024
description: Password.
client:
type: string
minLength: 1
maxLength: 64
description: Name of the client app.
clientVersion:
type: string
maxLength: 32
description: Version of the client app.
name:
type: string
minLength: 1
maxLength: 64
description: "Label for this grant. Defaults to `client`."
scopes:
type: array
maxItems: 32
description: "Scopes the grant may hold. Omit for `all`."
items:
$ref: '#/components/schemas/ScopeRequest'
GrantCreated:
type: object
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
required:
- secret
- grant
- user
properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:
- $ref: '#/components/schemas/Grant'
user:
description: The user the grant belongs to.
allOf:
- $ref: '#/components/schemas/AuthUser'
PasswordChangeRequest:
type: object
description: "Change the caller's own password."
required:
- currentPassword
- newPassword
properties:
currentPassword:
type: string
minLength: 1
maxLength: 1024
description: The current password.
newPassword:
type: string
minLength: 1
maxLength: 1024
description: The new password.
revokeOtherGrants:
type: boolean
default: true
description: "Revoke every other grant of the user. The calling grant always survives. Default true."
Grant:
type: object
description: A long-lived grant held by one client of one user.
required:
- id
- name
- client
- clientVersion
- scopes
- provider
- createdAt
- lastUsedAt
- lastUsedIp
- current
properties:
id:
type: string
description: Grant id.
name:
type: string
description: Label shown to the user.
client:
type: string
description: Name of the client app that holds the grant.
clientVersion:
type: string
nullable: true
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant carries.
items:
$ref: '#/components/schemas/Scope'
provider:
type: string
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
createdAt:
type: string
format: date-time
description: When the grant was created.
lastUsedAt:
type: string
format: date-time
nullable: true
description: "When the grant was last used, at a coarse granularity. Null until first use."
lastUsedIp:
type: string
nullable: true
description: Client IP of the last use. Null until first use.
current:
type: boolean
description: True for the grant that made this request.
Scope:
type: string
description: |
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
enum:
- all
- read
- password
ScopeRequest:
type: string
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
pattern: '^[a-z][a-z-]*(:write)?$'
maxLength: 64
AuthUser:
type: object
description: The user a grant belongs to.
required:
- id
- userName
- name
- isAdmin
- passwordChangeable
properties:
id:
type: string
description: User id.
userName:
type: string
description: Login name.
name:
type: string
description: Display name.
isAdmin:
type: boolean
description: Whether the user is an administrator.
passwordChangeable:
type: boolean
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
responses: responses:
InternalError: InternalError:
description: Unexpected server failure. Details are in the server log. description: Unexpected server failure. Details are in the server log.
@ -641,96 +182,13 @@ components:
application/problem+json: application/problem+json:
schema: schema:
$ref: '#/components/schemas/Problem' $ref: '#/components/schemas/Problem'
Unauthorized:
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotModified: NotModified:
description: Not modified. description: Not modified.
headers: headers:
ETag: ETag:
$ref: '#/components/headers/ETag' $ref: '#/components/headers/ETag'
BadRequest:
description: The request is malformed or fails validation.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Forbidden:
description: The caller is authenticated but not allowed to do this.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotFound:
description: No such resource or endpoint.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
PayloadTooLarge:
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
TooManyRequests:
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
headers:
Retry-After:
description: Seconds to wait before retrying.
schema:
type: integer
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Conflict:
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
parameters:
offset:
name: offset
in: query
description: Zero-based index of the first item to return.
required: false
schema:
type: integer
minimum: 0
default: 0
limit:
name: limit
in: query
description: Maximum number of items to return.
required: false
schema:
type: integer
minimum: 1
maximum: 2000
default: 100
headers: headers:
WWWAuthenticate:
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string
ETag: ETag:
description: Entity tag for `If-None-Match` revalidation. description: Entity tag for `If-None-Match` revalidation.
schema: schema:
type: string type: string
CacheControlNoStore:
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum:
- no-store

View file

@ -1,4 +0,0 @@
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum: [no-store]

View file

@ -1,3 +0,0 @@
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string

View file

@ -1,5 +0,0 @@
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,7 +1,4 @@
description: The caller is authenticated but not allowed to do this. description: The caller is authenticated but not allowed to do this.
headers:
WWW-Authenticate:
$ref: ../headers/WWWAuthenticate.yaml
content: content:
application/problem+json: application/problem+json:
schema: schema:

View file

@ -1,5 +0,0 @@
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,10 +0,0 @@
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
headers:
Retry-After:
description: Seconds to wait before retrying.
schema:
type: integer
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,7 +1,4 @@
description: Missing, invalid, or expired credentials. description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: ../headers/WWWAuthenticate.yaml
content: content:
application/problem+json: application/problem+json:
schema: schema:

View file

@ -1,19 +0,0 @@
type: object
description: The user a grant belongs to.
required: [id, userName, name, isAdmin, passwordChangeable]
properties:
id:
type: string
description: User id.
userName:
type: string
description: Login name.
name:
type: string
description: Display name.
isAdmin:
type: boolean
description: Whether the user is an administrator.
passwordChangeable:
type: boolean
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."

View file

@ -1,10 +0,0 @@
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling grant may use.
properties:
core:
$ref: ./CoreCapability.yaml
password:
$ref: ./PasswordCapability.yaml

View file

@ -1,5 +0,0 @@
type: object
description: The mandatory core module.
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -1,34 +0,0 @@
type: object
description: "Username, password and client description for a login or first-admin setup."
required: [username, password, client]
properties:
username:
type: string
minLength: 1
maxLength: 255
description: Login name.
password:
type: string
minLength: 1
maxLength: 1024
description: Password.
client:
type: string
minLength: 1
maxLength: 64
description: Name of the client app.
clientVersion:
type: string
maxLength: 32
description: Version of the client app.
name:
type: string
minLength: 1
maxLength: 64
description: "Label for this grant. Defaults to `client`."
scopes:
type: array
maxItems: 32
description: "Scopes the grant may hold. Omit for `all`."
items:
$ref: ./ScopeRequest.yaml

View file

@ -1,41 +0,0 @@
type: object
description: A long-lived grant held by one client of one user.
required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current]
properties:
id:
type: string
description: Grant id.
name:
type: string
description: Label shown to the user.
client:
type: string
description: Name of the client app that holds the grant.
clientVersion:
type: string
nullable: true
description: "Version of the client app, when it sent one."
scopes:
type: array
description: Scopes this grant carries.
items:
$ref: ./Scope.yaml
provider:
type: string
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
createdAt:
type: string
format: date-time
description: When the grant was created.
lastUsedAt:
type: string
format: date-time
nullable: true
description: "When the grant was last used, at a coarse granularity. Null until first use."
lastUsedIp:
type: string
nullable: true
description: Client IP of the last use. Null until first use.
current:
type: boolean
description: True for the grant that made this request.

View file

@ -1,16 +0,0 @@
type: object
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
required: [secret, grant, user]
properties:
secret:
type: string
maxLength: 512
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
grant:
description: The new grant.
allOf:
- $ref: ./Grant.yaml
user:
description: The user the grant belongs to.
allOf:
- $ref: ./AuthUser.yaml

View file

@ -1,18 +0,0 @@
type: object
description: "A page of the caller's grants."
required: [items, total, offset, limit]
properties:
items:
type: array
description: "Grants on this page, by last use, most recent first; never-used grants last."
items:
$ref: ./Grant.yaml
total:
type: integer
description: Total number of grants.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.

View file

@ -1,8 +0,0 @@
type: object
description: |
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
Keys are optional on purpose: discovery is read by clients of any version against servers of any
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: ./PasswordLoginMethod.yaml

View file

@ -1,8 +0,0 @@
type: object
description: Result of a logout.
required: [logoutUrl]
properties:
logoutUrl:
type: string
nullable: true
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."

View file

@ -1,5 +0,0 @@
type: object
description: The password login module (login, first-admin setup, password change).
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -1,18 +0,0 @@
type: object
description: "Change the caller's own password."
required: [currentPassword, newPassword]
properties:
currentPassword:
type: string
minLength: 1
maxLength: 1024
description: The current password.
newPassword:
type: string
minLength: 1
maxLength: 1024
description: The new password.
revokeOtherGrants:
type: boolean
default: true
description: "Revoke every other grant of the user. The calling grant always survives. Default true."

View file

@ -1,2 +0,0 @@
type: object
description: Username and password login (`POST /auth/login`). No settings yet.

View file

@ -16,7 +16,7 @@ properties:
description: HTTP status code of this response. description: HTTP status code of this response.
detail: detail:
type: string type: string
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code: code:
type: string type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added. description: Machine-readable error code, and the value clients switch on. New codes may be added.
@ -24,18 +24,10 @@ properties:
- validation - validation
- unauthorized - unauthorized
- forbidden - forbidden
- insufficient_scope
- not_found - not_found
- method_not_allowed - method_not_allowed
- setup_complete
- password_managed_externally
- payload_too_large
- rate_limited
- unavailable - unavailable
- internal - internal
referenceId:
type: string
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
errors: errors:
type: array type: array
description: Per-field failures. Present only when `code` is `validation`. description: Per-field failures. Present only when `code` is `validation`.

View file

@ -1,5 +0,0 @@
type: string
description: |
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
enum: [all, read, password]

View file

@ -1,4 +0,0 @@
type: string
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
pattern: '^[a-z][a-z-]*(:write)?$'
maxLength: 64

View file

@ -15,4 +15,8 @@ properties:
type: boolean type: boolean
description: True until the first admin user has been created. description: True until the first admin user has been created.
loginMethods: loginMethods:
$ref: ./LoginMethods.yaml type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum: [password]

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0 version: 1.0.0
description: | description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /capabilities` and never sniff versions. capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing. accept values they do not recognise instead of failing.
@ -15,10 +15,6 @@ info:
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header. in its `Allow` header.
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
license: license:
name: GPL-3.0 name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html url: https://www.gnu.org/licenses/gpl-3.0.html
@ -27,32 +23,17 @@ servers:
tags: tags:
- name: server - name: server
description: Server discovery and the published OpenAPI document. description: Server discovery and the published OpenAPI document.
- name: auth
description: Grants and login methods.
paths: paths:
/server: /server:
$ref: ./paths/server.yaml $ref: ./paths/server.yaml
/capabilities:
$ref: ./paths/capabilities.yaml
/openapi.json: /openapi.json:
$ref: ./paths/openapi.yaml#/json $ref: ./paths/openapi.yaml#/json
/openapi.yaml: /openapi.yaml:
$ref: ./paths/openapi.yaml#/yaml $ref: ./paths/openapi.yaml#/yaml
/auth/grants:
$ref: ./paths/auth.yaml#/grants
/auth/grants/{id}:
$ref: ./paths/auth.yaml#/grant
/auth/logout:
$ref: ./paths/auth.yaml#/logout
/auth/login:
$ref: ./paths/auth.yaml#/login
/auth/setup:
$ref: ./paths/auth.yaml#/setup
/auth/password:
$ref: ./paths/auth.yaml#/password
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
scheme: bearer scheme: bearer
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.

View file

@ -1,188 +0,0 @@
grants:
get:
operationId: listGrants
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: List my grants
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
security: [{bearerAuth: []}]
parameters:
- $ref: ../components/parameters/offset.yaml
- $ref: ../components/parameters/limit.yaml
responses:
'200':
description: A page of grants.
content:
application/json:
schema:
$ref: ../components/schemas/GrantList.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'500':
$ref: ../components/responses/InternalError.yaml
grant:
delete:
operationId: revokeGrant
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Revoke one of my grants
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
security: [{bearerAuth: []}]
parameters:
- name: id
in: path
required: true
description: Grant id.
schema:
type: string
maxLength: 64
responses:
'204':
description: Revoked.
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'404':
$ref: ../components/responses/NotFound.yaml
'500':
$ref: ../components/responses/InternalError.yaml
logout:
post:
operationId: logout
x-module: core
x-scope: read
x-stability-level: alpha
tags: [auth]
summary: Log out
description: Revokes the grant that made this request.
security: [{bearerAuth: []}]
responses:
'200':
description: Logged out.
content:
application/json:
schema:
$ref: ../components/schemas/LogoutResponse.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'500':
$ref: ../components/responses/InternalError.yaml
login:
post:
operationId: login
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Log in with a password
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/CredentialsRequest.yaml
responses:
'200':
description: The new grant.
headers:
Cache-Control:
$ref: ../components/headers/CacheControlNoStore.yaml
content:
application/json:
schema:
$ref: ../components/schemas/GrantCreated.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml
setup:
post:
operationId: setupFirstAdmin
x-module: password
x-stability-level: alpha
tags: [auth]
summary: Create the first admin
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
security: []
requestBody:
description: The credentials and a description of the client.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/CredentialsRequest.yaml
responses:
'201':
description: The admin was created.
headers:
Cache-Control:
$ref: ../components/headers/CacheControlNoStore.yaml
content:
application/json:
schema:
$ref: ../components/schemas/GrantCreated.yaml
'400':
$ref: ../components/responses/BadRequest.yaml
'409':
$ref: ../components/responses/Conflict.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml
password:
post:
operationId: changePassword
x-module: password
x-scope: password
x-stability-level: alpha
tags: [auth]
summary: Change my password
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
security: [{bearerAuth: []}]
requestBody:
description: The current and the new password.
required: true
content:
application/json:
schema:
$ref: ../components/schemas/PasswordChangeRequest.yaml
responses:
'204':
description: Password changed.
'400':
$ref: ../components/responses/BadRequest.yaml
'401':
$ref: ../components/responses/Unauthorized.yaml
'403':
$ref: ../components/responses/Forbidden.yaml
'409':
$ref: ../components/responses/Conflict.yaml
'413':
$ref: ../components/responses/PayloadTooLarge.yaml
'429':
$ref: ../components/responses/TooManyRequests.yaml
'500':
$ref: ../components/responses/InternalError.yaml

View file

@ -1,16 +0,0 @@
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: ../components/schemas/Capabilities.yaml}
'401': {$ref: ../components/responses/Unauthorized.yaml}
'500': {$ref: ../components/responses/InternalError.yaml}

View file

@ -4,7 +4,6 @@ json:
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Get the OpenAPI document (JSON) summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses: responses:
@ -26,7 +25,6 @@ yaml:
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Get the OpenAPI document (YAML) summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses: responses:

View file

@ -3,11 +3,11 @@ get:
x-module: core x-module: core
x-stability-level: alpha x-stability-level: alpha
tags: [server] tags: [server]
security: []
summary: Describe the server summary: Describe the server
description: | description: |
Returns the public server description. No authentication required. Returns the public server description. No authentication required.
Capability modules are listed by `GET /capabilities`. Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses: responses:
'200': '200':
description: Server description. description: Server description.

View file

@ -1,13 +1,17 @@
package cmd package cmd
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"strings" "strings"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{
Long: "Show file tags as seen by Navidrome", Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
runInspector(args) runInspector(cmd.Context(), args)
}, },
} }
@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) {
return []byte(res.String()), nil return []byte(res.String()), nil
} }
func runInspector(args []string) { func runInspector(ctx context.Context, args []string) {
marshal := marshalers[format] marshal := marshalers[format]
if marshal == nil { if marshal == nil {
log.Fatal("Invalid format", "format", format) log.Fatal("Invalid format", "format", format)
} }
libs := loadLibraries(ctx)
matcher := model.NewLibraryMatcher(libs)
var out []core.InspectOutput var out []core.InspectOutput
for _, filePath := range args { for _, filePath := range args {
if !model.IsAudioFile(filePath) { if !model.IsAudioFile(filePath) {
log.Warn("Not an audio file", "file", filePath) log.Warn("Not an audio file", "file", filePath)
continue continue
} }
output, err := core.Inspect(filePath, 1, "") lib, ok := libraryForFile(matcher, filePath)
if !ok && len(libs) > 0 {
log.Warn("File is not in any library, using the global PID config", "file", filePath)
}
output, err := core.Inspect(filePath, lib, "")
if err != nil { if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err) log.Warn("Unable to process file", "file", filePath, "error", err)
continue continue
@ -77,3 +87,33 @@ func runInspector(args []string) {
data, _ := marshal(out) data, _ := marshal(out)
fmt.Println(string(data)) fmt.Println(string(data))
} }
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
func loadLibraries(ctx context.Context) model.Libraries {
if dbFile, ok := existingDBFile(); !ok {
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
return nil
}
defer db.Init(ctx)()
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
if err != nil {
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
return nil
}
for i := range libs {
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
libs[i].Path = absPath
}
}
return libs
}
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
if absPath, err := filepath.Abs(filePath); err == nil {
if lib, ok := matcher.FindLibrary(absPath); ok {
return lib, true
}
}
return model.Library{ID: model.DefaultLibraryID}, false
}

61
cmd/inspect_test.go Normal file
View file

@ -0,0 +1,61 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("inspect", func() {
Describe("libraryForFile", func() {
var matcher *model.LibraryMatcher
var root string
BeforeEach(func() {
root = GinkgoT().TempDir()
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
matcher = model.NewLibraryMatcher(model.Libraries{
{ID: 1, Path: filepath.Join(root, "music")},
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
})
})
It("returns the library that contains an absolute path", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.ID).To(Equal(1))
})
It("resolves a relative path against the working directory", func() {
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.PIDAlbum).To(Equal("folder"))
})
It("falls back to the default library without overrides", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
Expect(ok).To(BeFalse())
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
})
})
Describe("loadLibraries", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not create a database when there is none", func() {
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
Expect(dbFile).ToNot(BeAnExistingFile())
})
})
})

View file

@ -190,16 +190,20 @@ func schedulePeriodicScan(ctx context.Context) func() error {
} }
} }
func pidHashChanged(ds model.DataStore) (bool, error) { // librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "") // the one used by their last finished scan
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
libs, err := ds.Library().GetAll(ctx)
if err != nil { if err != nil {
return false, err return nil, err
} }
pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "") var names []string
if err != nil { for _, lib := range libs {
return false, err if lib.PIDChanged() {
names = append(names, lib.Name)
}
} }
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil return names, nil
} }
// runInitialScan runs an initial scan of the music library if needed. // runInitialScan runs an initial scan of the music library if needed.
@ -214,12 +218,12 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil { if err != nil {
return err return err
} }
pidHasChanged, err := pidHashChanged(ds) pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
if err != nil { if err != nil {
return err return err
} }
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded { if scanNeeded {
s := CreateScanner(ctx) s := CreateScanner(ctx)
@ -227,9 +231,9 @@ func runInitialScan(ctx context.Context) func() error {
case fullScanRequired == "1": case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration") log.Warn(ctx, "Full scan required after migration")
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
case pidHasChanged: case len(pidChangedLibs) > 0:
log.Warn(ctx, "PID config changed, performing full scan") // Includes never-scanned libraries. The scanner rescans in full only the ones that need it
fullScanRequired = "1" log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
case inProgress: case inProgress:
log.Warn(ctx, "Resuming interrupted scan") log.Warn(ctx, "Resuming interrupted scan")
default: default:

View file

@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path" "path"
@ -9,6 +10,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() {
Entry("with a trailing-slash BasePath", "/music/"), Entry("with a trailing-slash BasePath", "/music/"),
) )
}) })
var _ = Describe("librariesWithChangedPID", func() {
var ds *tests.MockDataStore
var libs *tests.MockLibraryRepo
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
libs = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{MockedLibrary: libs}
})
It("returns only the libraries whose PID config changed", func() {
pid := model.Library{}.EffectivePID()
libs.SetData(model.Libraries{
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 3, Name: "Never scanned"},
})
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
})
It("returns the error from the repository", func() {
libs.Err = errors.New("db down")
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
Expect(err).To(MatchError("db down"))
})
})

View file

@ -18,11 +18,16 @@ import (
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
) )
// requireExistingDB aborts the command when the database file (DbPath minus DSN // existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
// params) does not exist. func existingDBFile() (string, bool) {
func requireExistingDB() {
path, _, _ := strings.Cut(conf.Server.DbPath, "?") path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) { _, err := os.Stat(path)
return path, err == nil
}
// requireExistingDB aborts the command when the database file does not exist.
func requireExistingDB() {
if path, ok := existingDBFile(); !ok {
log.Fatal("No existing database", "path", path) log.Fatal("No existing database", "path", path)
} }
} }

View file

@ -95,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache() transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore) share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
players := core.NewPlayers(dataStore) players := core.NewPlayers(dataStore)
broker := events.GetBroker() broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@ -110,7 +111,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore) playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager) lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher) sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router return router
@ -159,9 +159,10 @@ func CreatePublicRouter() *public.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache() transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore) share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
return router return router
} }

View file

@ -34,7 +34,6 @@ const (
JWTPublicSecretKey = "JWTPublicSecret" JWTPublicSecretKey = "JWTPublicSecret"
JWTIssuer = "ND" JWTIssuer = "ND"
DefaultSessionTimeout = 48 * time.Hour DefaultSessionTimeout = 48 * time.Hour
APIv1GrantIdleExpiry = 90 * 24 * time.Hour
DefaultSmartRefresh = 5 * time.Second DefaultSmartRefresh = 5 * time.Second
DefaultShareExpiration = 8760 * time.Hour DefaultShareExpiration = 8760 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year CookieExpiry = 365 * 24 * 3600 // One year
@ -157,8 +156,6 @@ const (
//DefaultAlbumPID = "album_legacy" //DefaultAlbumPID = "album_legacy"
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
PIDAlbumKey = "PIDAlbum"
PIDTrackKey = "PIDTrack"
) )
const ( const (

View file

@ -3,7 +3,6 @@ package agents
import ( import (
"context" "context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
) )
@ -14,7 +13,6 @@ type SessionKeys struct {
} }
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
ctx = log.WithSecrets(ctx, sessionKey)
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
} }

View file

@ -1,31 +1,21 @@
package agents package agents
import ( import (
"bytes"
"context" "context"
"database/sql"
"os"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
"github.com/pocketbase/dbx"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("SessionKeys", func() { var _ = Describe("SessionKeys", func() {
var ctx context.Context ctx := context.Background()
user := model.User{ID: "u-1"} user := model.User{ID: "u-1"}
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}} ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"} sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("uses the assigned key name", func() { It("uses the assigned key name", func() {
Expect(sk.KeyName).To(Equal("fakeSessionKey")) Expect(sk.KeyName).To(Equal("fakeSessionKey"))
}) })
@ -44,34 +34,4 @@ var _ = Describe("SessionKeys", func() {
_, err := sk.Get(ctx, "u-2") _, err := sk.Get(ctx, "u-2")
Expect(err).To(MatchError(model.ErrNotFound)) Expect(err).To(MatchError(model.ErrNotFound))
}) })
It("never logs the session key, but still logs the user id and key name", func() {
conn, err := sql.Open("sqlite3", ":memory:")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(conn.Close)
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(ctx, "create table user_props (user_id varchar, key varchar, value varchar)")
Expect(err).ToNot(HaveOccurred())
props := persistence.NewUserPropsRepository(dbx.NewFromDB(conn, "sqlite3"))
dbKeys := SessionKeys{DataStore: &tests.MockDataStore{MockedUserProps: props}, KeyName: "LastFMSessionKey"}
logs := &bytes.Buffer{}
log.SetOutput(logs)
log.SetLevel(log.LevelTrace)
DeferCleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(log.LevelFatal)
})
Expect(dbKeys.Put(ctx, "logged-user-id", "inserted-session-key")).To(Succeed())
Expect(dbKeys.Put(ctx, "logged-user-id", "updated-session-key")).To(Succeed())
Expect(dbKeys.Get(ctx, "logged-user-id")).To(Equal("updated-session-key"))
Expect(logs.String()).To(ContainSubstring("INSERT INTO user_props"))
Expect(logs.String()).To(ContainSubstring("UPDATE user_props"))
Expect(logs.String()).To(ContainSubstring("logged-user-id"))
Expect(logs.String()).To(ContainSubstring("LastFMSessionKey"))
Expect(logs.String()).ToNot(ContainSubstring("inserted-session-key"))
Expect(logs.String()).ToNot(ContainSubstring("updated-session-key"))
})
}) })

View file

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

View file

@ -1,14 +0,0 @@
package apiauth
import "context"
type principalKey struct{}
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
func PrincipalFrom(ctx context.Context) (*Principal, bool) {
p, ok := ctx.Value(principalKey{}).(*Principal)
return p, ok
}

View file

@ -1,68 +0,0 @@
package apiauth
import (
"context"
"crypto/subtle"
"errors"
"github.com/navidrome/navidrome/model"
)
type Outcome int
const (
NotMine Outcome = iota
Authenticated
Rejected
Unavailable
)
type CredentialResult struct {
Outcome Outcome
User *model.User
Provider string
PasswordLocal bool
}
type CredentialChecker interface {
Check(ctx context.Context, username, password string) (CredentialResult, error)
}
// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final.
func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) {
for _, c := range checkers {
res, err := c.Check(ctx, username, password)
if err != nil {
return CredentialResult{}, err
}
switch res.Outcome {
case NotMine:
continue
case Authenticated:
return res, nil
case Unavailable:
return CredentialResult{}, model.ErrNotAvailable
default:
return CredentialResult{}, model.ErrInvalidAuth
}
}
return CredentialResult{}, model.ErrInvalidAuth
}
type dbChecker struct {
ds model.DataStore
}
func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
u, err := c.ds.User().FindByUsernameWithPassword(ctx, username)
if errors.Is(err, model.ErrNotFound) {
return CredentialResult{Outcome: NotMine}, nil
}
if err != nil {
return CredentialResult{}, err
}
if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 {
return CredentialResult{Outcome: Rejected}, nil
}
return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil
}

View file

@ -1,63 +0,0 @@
package apiauth
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type fakeChecker struct {
res CredentialResult
err error
hit bool
}
func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) {
f.hit = true
return f.res, f.err
}
var _ = Describe("credential chain", func() {
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
})
It("authenticates against the database with the stored password", func() {
u := createUser(ctx, "pw", false)
res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw")
Expect(err).ToNot(HaveOccurred())
Expect(res.Outcome).To(Equal(Authenticated))
Expect(res.User.ID).To(Equal(u.ID))
Expect(res.Provider).To(Equal("password"))
Expect(res.PasswordLocal).To(BeTrue())
})
It("rejects a wrong password and an unknown user the same way", func() {
u := createUser(ctx, "pw", false)
_, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("moves on only from NotMine, and an owner's rejection stops the chain", func() {
owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}}
later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}}
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b")
Expect(err).To(MatchError(model.ErrInvalidAuth))
Expect(later.hit).To(BeFalse())
})
It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() {
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b")
Expect(err).To(MatchError(model.ErrNotAvailable))
boom := errors.New("boom")
_, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b")
Expect(err).To(MatchError(boom))
})
})

View file

@ -1,43 +0,0 @@
package apiauth
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var realDS model.DataStore
// One database for the whole suite: db.Db() is a process-wide singleton.
var _ = BeforeSuite(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiauth.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
DeferCleanup(db.Init(GinkgoT().Context()))
realDS = persistence.New(db.Db())
})
func createUser(ctx context.Context, password string, admin bool) model.User {
name := "user-" + id.NewRandom()
u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin}
ExpectWithOffset(1, realDS.User().Put(ctx, &u)).To(Succeed())
stored, err := realDS.User().FindByUsername(ctx, name)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return *stored
}
// login signs u in (nil scopes asks for all) and authenticates with the new grant secret.
func login(ctx context.Context, svc *Service, u model.User, password string, scopes []string) (*Issued, *Principal) {
issued, err := svc.Login(ctx, u.UserName, password, meta, scopes)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
p, err := svc.Authenticate(ctx, issued.Secret, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return issued, p
}

View file

@ -1,11 +0,0 @@
package apiauth
import (
"time"
"github.com/navidrome/navidrome/model"
)
func (s *Service) SetClock(now func() time.Time) { s.now = now }
func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f }

View file

@ -1,63 +0,0 @@
package apiauth
import (
"slices"
"strings"
)
const (
ScopeAll = "all"
ScopeRead = "read"
ScopePassword = "password"
ScopeAdmin = "admin"
)
// KnownScopes lists the scopes of modules this server implements; `all` expands to these.
var KnownScopes = []string{ScopeRead, ScopePassword}
func known(s string) bool {
return slices.Contains(KnownScopes, s)
}
func grantable(s string, isAdmin bool) bool {
return known(s) && (s != ScopeAdmin || isAdmin)
}
func normalize(in []string) []string {
out := slices.Clone(in)
slices.Sort(out)
return slices.Compact(out)
}
// Entitled returns the scopes a new grant stores.
func Entitled(requested []string, isAdmin bool) []string {
if requested == nil {
return []string{ScopeAll}
}
var out []string
for _, s := range requested {
if s == ScopeAll || grantable(s, isAdmin) {
out = append(out, s)
}
}
return normalize(out)
}
// Expand turns a grant's stored scopes into the concrete scopes it carries right now.
func Expand(granted []string, isAdmin bool) []string {
var out []string
for _, s := range granted {
if s == ScopeAll {
out = append(out, KnownScopes...)
continue
}
out = append(out, s)
}
out = slices.DeleteFunc(out, func(s string) bool { return !grantable(s, isAdmin) })
return normalize(out)
}
func Satisfies(scopes []string, required string) bool {
return slices.Contains(scopes, required) ||
(!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write"))
}

View file

@ -1,50 +0,0 @@
package apiauth
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("scopes", func() {
BeforeEach(func() {
saved := KnownScopes
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin, "playlists", "playlists:write"}
DeferCleanup(func() { KnownScopes = saved })
})
Describe("Entitled", func() {
It("stores all when nothing is requested", func() {
Expect(Entitled(nil, false)).To(Equal([]string{ScopeAll}))
})
It("drops unknown scopes and admin for non-admins", func() {
Expect(Entitled([]string{"read", "future", "admin"}, false)).To(Equal([]string{"read"}))
})
It("keeps admin for admins and keeps all", func() {
Expect(Entitled([]string{"admin", "all"}, true)).To(Equal([]string{"admin", "all"}))
})
})
Describe("Expand", func() {
It("replaces all with every known scope except admin for non-admins", func() {
Expect(Expand([]string{ScopeAll}, false)).To(Equal([]string{"password", "playlists", "playlists:write", "read"}))
})
It("includes admin for admins", func() {
Expect(Expand([]string{ScopeAll}, true)).To(ContainElement("admin"))
})
It("drops admin from explicit scopes when the user is no longer an admin", func() {
Expect(Expand([]string{"admin", "read"}, false)).To(Equal([]string{"read"}))
})
It("drops scopes that are no longer known", func() {
Expect(Expand([]string{"read", "retired"}, false)).To(Equal([]string{"read"}))
})
})
Describe("Satisfies", func() {
It("accepts the exact scope or its :write form", func() {
Expect(Satisfies([]string{"read"}, "read")).To(BeTrue())
Expect(Satisfies([]string{"playlists:write"}, "playlists")).To(BeTrue())
Expect(Satisfies([]string{"playlists"}, "playlists:write")).To(BeFalse())
Expect(Satisfies(nil, "read")).To(BeFalse())
})
})
})

View file

@ -1,20 +0,0 @@
package apiauth
import (
"crypto/sha256"
"encoding/hex"
"github.com/navidrome/navidrome/model/id"
)
const secretPrefix = "ndg_"
func newSecret() (secret, hash string) {
secret = secretPrefix + id.NewRandom()
return secret, hashSecret(secret)
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}

View file

@ -1,23 +0,0 @@
package apiauth
import (
"regexp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("grant secrets", func() {
It("are ndg_ plus 22 base62 characters, hashed as hex SHA-256", func() {
secret, hash := newSecret()
Expect(secret).To(MatchRegexp(`^ndg_[0-9A-Za-z]{22}$`))
Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`))
Expect(hashSecret(secret)).To(Equal(hash))
})
It("are unique", func() {
a, _ := newSecret()
b, _ := newSecret()
Expect(a).ToNot(Equal(b))
Expect(regexp.MustCompile(`^ndg_`).MatchString(a)).To(BeTrue())
})
})

View file

@ -1,264 +0,0 @@
package apiauth
import (
"cmp"
"context"
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
)
const (
IdleExpiry = consts.APIv1GrantIdleExpiry
touchInterval = 5 * time.Minute
)
var (
ErrPasswordManagedExternally = errors.New("password is managed externally")
ErrCurrentPasswordMismatch = errors.New("current password does not match")
)
type ClientMeta struct {
Name string
Client string
ClientVersion string
}
type Issued struct {
Secret string
Grant model.Grant
User model.User
}
type Principal struct {
User model.User
GrantID string
Scopes []string
}
type Service struct {
ds model.DataStore
checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction
now func() time.Time
}
func New(ds model.DataStore) *Service {
return &Service{
ds: ds,
checkers: func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{dbChecker{ds: ds}}
},
now: time.Now,
}
}
func PasswordChangeable(u model.User) bool {
return u.IsAdmin || conf.Server.EnableUserEditing
}
func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
res, err := checkCredentials(ctx, s.checkers(s.ds), username, password)
if err != nil {
return nil, err
}
issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes)
if err != nil {
return nil, err
}
if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil {
log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err)
}
return issued, nil
}
func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
var issued *Issued
_, err := auth.CreateFirstAdmin(ctx, s.ds, username, password, func(tx model.DataStore, u *model.User) error {
var err error
issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes)
return err
})
if err != nil {
return nil, err
}
return issued, nil
}
// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead.
func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) {
u.Password = ""
secret, hash := newSecret()
g := model.Grant{
UserID: u.ID,
Name: cmp.Or(meta.Name, meta.Client),
Client: meta.Client,
ClientVersion: meta.ClientVersion,
Scopes: Entitled(scopes, u.IsAdmin),
Provider: provider,
SecretHash: hash,
UserEpoch: u.TokenEpoch,
CreatedAt: s.now(),
}
if err := ds.Grant().Put(ctx, &g); err != nil {
return nil, fmt.Errorf("storing grant: %w", err)
}
return &Issued{Secret: secret, Grant: g, User: u}, nil
}
func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Principal, error) {
g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret))
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
}
if err != nil {
return nil, err
}
if idleSince := s.now().Add(-IdleExpiry); g.LastActivity().Before(idleSince) {
s.dropIdle(ctx, g.ID, idleSince)
return nil, model.ErrInvalidAuth
}
u, err := s.ds.User().Get(ctx, g.UserID)
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrInvalidAuth
}
if err != nil {
return nil, err
}
if g.UserEpoch != u.TokenEpoch {
if g, u, err = s.settleEpoch(ctx, g.ID); err != nil {
return nil, err
}
}
s.touch(ctx, g, ip)
return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil
}
// dropIdle deletes only still-idle grants, sparing one renewed meanwhile.
func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) {
if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil {
log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err)
}
}
// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change
// and make a kept grant look dead. Deleting below the snapshot's epoch is safe: a later change only moves kept grants up.
func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) {
var g *model.Grant
var u *model.User
err := s.ds.WithTx(func(tx model.DataStore) error {
var err error
if g, err = tx.Grant().Get(ctx, grantID); err != nil {
return err
}
u, err = tx.User().Get(ctx, g.UserID)
return err
})
if errors.Is(err, model.ErrNotFound) {
return nil, nil, model.ErrInvalidAuth
}
if err != nil {
return nil, nil, err
}
if g.UserEpoch != u.TokenEpoch {
if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil {
log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err)
}
return nil, nil, model.ErrInvalidAuth
}
return g, u, nil
}
// touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes.
func (s *Service) touch(ctx context.Context, g *model.Grant, ip string) {
now := s.now()
if lastUsed := gg.V(g.LastUsedAt); !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) {
return
}
if err := s.ds.Grant().Touch(ctx, g.ID, ip, now, now.Add(-touchInterval)); err != nil {
log.Warn(ctx, "API v1: could not record grant use", "grant", g.ID, err)
}
}
// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented.
func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) {
idleSince := s.now().Add(-IdleExpiry)
grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit)
if err != nil {
return nil, 0, err
}
total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince)
return grants, total, err
}
func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error {
return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID)
}
// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout.
func (s *Service) Logout(ctx context.Context, p *Principal) error {
err := s.RevokeGrant(ctx, p, p.GrantID)
if errors.Is(err, model.ErrNotFound) {
return nil
}
return err
}
// ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten.
func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, newPassword string, revokeOthers bool) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
u, err := tx.User().Get(ctx, p.User.ID)
if errors.Is(err, model.ErrNotFound) {
return model.ErrInvalidAuth
}
if err != nil {
return err
}
g, err := tx.Grant().Get(ctx, p.GrantID)
if errors.Is(err, model.ErrNotFound) {
return model.ErrInvalidAuth
}
if err != nil {
return err
}
if g.UserID != u.ID || g.UserEpoch != u.TokenEpoch {
return model.ErrInvalidAuth
}
if !PasswordChangeable(*u) {
return model.ErrNotAuthorized
}
res, err := checkCredentials(ctx, s.checkers(tx), u.UserName, current)
if errors.Is(err, model.ErrInvalidAuth) {
return ErrCurrentPasswordMismatch
}
if err != nil {
return err
}
if !res.PasswordLocal {
return ErrPasswordManagedExternally
}
oldEpoch := u.TokenEpoch
u.NewPassword = newPassword
if err := tx.User().Put(ctx, u); err != nil {
return err
}
updated, err := tx.User().Get(ctx, u.ID)
if err != nil {
return err
}
keep := ""
if revokeOthers {
keep = p.GrantID
}
if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil {
return err
}
return tx.Grant().DeleteStaleEpochs(ctx, u.ID, updated.TokenEpoch)
})
}

View file

@ -1,463 +0,0 @@
package apiauth
import (
"context"
"errors"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"}
var _ = Describe("Service", func() {
var ctx context.Context
var svc *Service
var now time.Time
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
now = time.Now().UTC().Truncate(time.Second)
svc = New(realDS)
svc.SetClock(func() time.Time { return now })
})
Describe("Login", func() {
It("creates a grant storing all, the user's epoch and the client metadata", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.Secret).To(HavePrefix("ndg_"))
Expect(issued.User.ID).To(Equal(u.ID))
Expect(issued.User.Password).To(BeEmpty())
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll}))
Expect(issued.Grant.Provider).To(Equal("password"))
Expect(issued.Grant.Name).To(Equal("Living room"))
Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch))
stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret))
Expect(err).ToNot(HaveOccurred())
Expect(stored.ID).To(Equal(issued.Grant.ID))
})
It("defaults the grant name to the client", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.Grant.Name).To(Equal("OnlyClient"))
})
It("accepts the username in any case", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
Expect(issued.User.ID).To(Equal(u.ID))
})
It("stores only known requested scopes", func() {
u := createUser(ctx, "pw", false)
issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"})
Expect(err).ToNot(HaveOccurred())
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead}))
})
It("fails with ErrInvalidAuth for bad credentials", func() {
u := createUser(ctx, "pw", false)
_, err := svc.Login(ctx, u.UserName, "wrong", meta, nil)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("Setup", func() {
It("refuses when users exist", func() {
createUser(ctx, "pw", false)
_, err := svc.Setup(ctx, "newadmin", "pw", meta, nil)
Expect(err).To(MatchError(auth.ErrSetupComplete))
})
// The empty-database path is covered end to end in server/apiv1, which owns a fresh DB.
})
Describe("Authenticate", func() {
It("resolves the secret to its user and the grant's expanded scopes", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(p.User.ID).To(Equal(u.ID))
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("carries only the scopes stored on a narrow grant", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", []string{ScopePassword})
Expect(p.Scopes).To(Equal([]string{ScopePassword}))
})
It("records the first use with the client IP", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedAt).ToNot(BeNil())
Expect(g.LastUsedIP).To(Equal("10.0.0.9"))
})
It("records use again only after the touch interval", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1")
Expect(err).ToNot(HaveOccurred())
now = now.Add(touchInterval - time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2")
Expect(err).ToNot(HaveOccurred())
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.1"))
now = now.Add(2 * time.Second)
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3")
Expect(err).ToNot(HaveOccurred())
g, _ = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(g.LastUsedIP).To(Equal("10.0.0.3"))
Expect(g.LastUsedAt.Equal(now)).To(BeTrue())
})
It("rejects unknown secrets", func() {
_, err := svc.Authenticate(ctx, "ndg_unknown", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("deletes and rejects a grant idle for 90 days, including one never used", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
now = now.Add(IdleExpiry + time.Second)
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("keeps an idle grant that a concurrent request renewed before the delete ran", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
renewedAt := now.Add(IdleExpiry - time.Minute)
racing := New(hookDS{DataStore: realDS, beforeDeleteIdle: func() {
Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed())
}})
now = now.Add(IdleExpiry + time.Second)
racing.SetClock(func() time.Time { return now })
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
g, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).ToNot(HaveOccurred())
Expect(g.LastUsedIP).To(Equal("10.0.0.2"))
})
It("rejects and deletes a grant whose epoch is behind the user's", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
u.NewPassword = "changed-elsewhere"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not delete a kept grant when the password changed between reading the grant and the user", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
racing := New(hookDS{DataStore: realDS, afterFind: func() {
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
}})
racing.SetClock(func() time.Time { return now })
_, err := racing.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = realDS.Grant().Get(ctx, p.GrantID)
Expect(err).ToNot(HaveOccurred())
})
It("drops admin from a grant once its user is no longer an admin", func() {
saved := KnownScopes
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin}
DeferCleanup(func() { KnownScopes = saved })
u := createUser(ctx, "pw", true)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(p.Scopes).To(ContainElement(ScopeAdmin))
u.IsAdmin = false
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
demoted, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
})
It("rejects the secret after its user is deleted, and the grant row is gone", func() {
u := createUser(ctx, "pw", false)
issued, _ := login(ctx, svc, u, "pw", nil)
Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed())
_, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("leaves a login that raced a password change with a dead grant", func() {
u := createUser(ctx, "pw", false)
reached, release := make(chan struct{}), make(chan struct{})
svc.SetCheckers(func(ds model.DataStore) []CredentialChecker {
return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}}
})
var issued *Issued
var loginErr error
done := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done)
issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil)
}()
<-reached // credentials (and the old epoch) were read
u.NewPassword = "changed-meanwhile"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
close(release)
<-done
Expect(loginErr).ToNot(HaveOccurred())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("grant management", func() {
It("lists the user's grants and marks the current one", func() {
u := createUser(ctx, "pw", false)
first, _ := login(ctx, svc, u, "pw", nil)
_, p := login(ctx, svc, u, "pw", nil)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(2)))
Expect(grants).To(HaveLen(2))
Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID))
})
It("lists only grants on the user's current epoch", func() {
u := createUser(ctx, "pw", false)
login(ctx, svc, u, "pw", nil)
u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
issued, p := login(ctx, svc, u, "reset-by-admin", nil)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(1)))
Expect(grants).To(HaveLen(1))
Expect(grants[0].ID).To(Equal(issued.Grant.ID))
})
It("logs out, and succeeds again when the grant is already gone", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
Expect(svc.Logout(ctx, p)).To(Succeed())
_, err := svc.Authenticate(ctx, issued.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
Expect(svc.Logout(ctx, p)).To(Succeed())
})
It("refuses to revoke another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
aliceGrant, _ := login(ctx, svc, alice, "pw", nil)
_, bobP := login(ctx, svc, bob, "pw", nil)
Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound))
_, err := svc.Authenticate(ctx, aliceGrant.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects the secret after its grant is revoked", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
_, p := login(ctx, svc, u, "pw", nil)
Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed())
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Describe("ChangePassword", func() {
It("revokes other grants by default and keeps the caller's", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
mine, p := login(ctx, svc, u, "pw", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed())
_, err := svc.Authenticate(ctx, mine.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, other.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = svc.Login(ctx, u.UserName, "pw2", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("keeps every grant, the caller's included, when revokeOthers is false", func() {
u := createUser(ctx, "pw", false)
other, _ := login(ctx, svc, u, "pw", nil)
mine, p := login(ctx, svc, u, "pw", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
_, err := svc.Authenticate(ctx, other.Secret, "")
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, mine.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
It("rejects a wrong current password without changing anything", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true)
Expect(err).To(MatchError(ErrCurrentPasswordMismatch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("is forbidden for non-admins when user editing is off", func() {
conf.Server.EnableUserEditing = false
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("does not revive grants killed by an earlier reset when keeping grants", func() {
u := createUser(ctx, "pw", false)
killed, _ := login(ctx, svc, u, "pw", nil)
u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, p2 := login(ctx, svc, u, "reset-by-admin", nil)
Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed())
_, err := svc.Authenticate(ctx, killed.Secret, "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rejects a caller whose grant was revoked before the change ran", func() {
u := createUser(ctx, "pw", false)
_, p := login(ctx, svc, u, "pw", nil)
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed())
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
})
It("rejects a caller naming another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
_, aliceP := login(ctx, svc, alice, "pw", nil)
bobGrant, _ := login(ctx, svc, bob, "pw", nil)
forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID}
err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("rolls back the password and epoch when a grant update fails", func() {
u := createUser(ctx, "pw", false)
issued, p := login(ctx, svc, u, "pw", nil)
failing := New(failingEpochDS{realDS})
failing.SetClock(func() time.Time { return now })
err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(ContainSubstring("boom")))
reloaded, _ := realDS.User().Get(ctx, u.ID)
Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, issued.Secret, "")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("PasswordChangeable", func() {
It("follows EnableUserEditing for non-admins only", func() {
conf.Server.EnableUserEditing = false
Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue())
Expect(PasswordChangeable(model.User{})).To(BeFalse())
conf.Server.EnableUserEditing = true
Expect(PasswordChangeable(model.User{})).To(BeTrue())
})
})
})
// hookDS runs its optional callbacks inside grant lookups, to land a concurrent change mid-Authenticate.
type hookDS struct {
model.DataStore
afterFind func()
beforeDeleteIdle func()
}
func (d hookDS) Grant() model.GrantRepository {
return hookGrants{GrantRepository: d.DataStore.Grant(), hooks: d}
}
type hookGrants struct {
model.GrantRepository
hooks hookDS
}
func (g hookGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
found, err := g.GrantRepository.FindBySecretHash(ctx, hash)
if g.hooks.afterFind != nil {
g.hooks.afterFind()
}
return found, err
}
func (g hookGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
if g.hooks.beforeDeleteIdle != nil {
g.hooks.beforeDeleteIdle()
}
return g.GrantRepository.DeleteIdle(ctx, idleSince)
}
type pausingChecker struct {
inner CredentialChecker
reached, release chan struct{}
}
func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
res, err := c.inner.Check(ctx, username, password)
close(c.reached)
<-c.release
return res, err
}
// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back.
type failingEpochDS struct{ model.DataStore }
func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
return f.DataStore.WithTxImmediate(func(tx model.DataStore) error {
return block(failingEpochTx{tx})
}, scope...)
}
type failingEpochTx struct{ model.DataStore }
func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} }
type failingGrants struct{ model.GrantRepository }
func (failingGrants) SetEpoch(context.Context, string, int, int, string) error {
return errors.New("boom")
}

View file

@ -35,13 +35,14 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
} }
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
} }
type archiver struct { type archiver struct {
ds model.DataStore ds model.DataStore
ms stream.MediaStreamer ms stream.MediaStreamer
decider stream.TranscodeDecider
shares Share shares Share
artwork artwork.Artwork artwork artwork.Artwork
} }
@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album { for _, mf := range album {
file := a.albumFilename(mf, format, isMultiDisc, folder) req := a.resolveRequest(ctx, &mf, format, bitrate)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Stop iterating: continuing would just rack up more // Stop iterating: continuing would just rack up more
// rejections from the limiter. Close finalises whatever // rejections from the limiter. Close finalises whatever
// tracks were already written; the rejected one is not // tracks were already written; the rejected one is not
@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
zippedMfs := make(model.MediaFiles, len(mfs)) zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs { for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx) req := a.resolveRequest(ctx, &mf, format, bitrate)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { file := a.playlistFilename(mf, req.Format, idx)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Abort the whole archive: continuing would silently emit // Abort the whole archive: continuing would silently emit
// empty zip entries since the headers are already written. // empty zip entries since the headers are already written.
_ = z.Close() _ = z.Close()
@ -251,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
} }
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
if format == "" || format == "raw" {
return stream.Request{Format: "raw"}
}
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
path := mf.AbsolutePath() path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection // Open the source before writing the zip entry header so a rejection
@ -259,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
// archive. // archive.
var r io.ReadCloser var r io.ReadCloser
var err error var err error
if format != "raw" && format != "" { if req.Format != "raw" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) r, err = a.ms.NewStream(ctx, &mf, req)
} else { } else {
r, err = os.Open(path) r, err = os.Open(path)
} }
if err != nil { if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
return err return err
} }
defer func() { defer func() {

View file

@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() {
var ( var (
arch core.Archiver arch core.Archiver
ms *mockMediaStreamer ms *mockMediaStreamer
dc *fakeDecider
ds *mockDataStore ds *mockDataStore
sh *mockShare sh *mockShare
ca *mockCoverArt ca *mockCoverArt
@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() {
BeforeEach(func() { BeforeEach(func() {
ms = &mockMediaStreamer{} ms = &mockMediaStreamer{}
dc = &fakeDecider{}
sh = &mockShare{} sh = &mockShare{}
ds = &mockDataStore{} ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}} ca = &mockCoverArt{images: map[string][]byte{}}
arch = core.NewArchiver(ms, ds, sh, ca) arch = core.NewArchiver(ms, dc, ds, sh, ca)
}) })
Context("ZipAlbum", func() { Context("ZipAlbum", func() {
@ -66,6 +68,23 @@ var _ = Describe("Archiver", func() {
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
}) })
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
ds.On("MediaFile").Return(mfRepo)
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
dc.resolved = &resolved
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
out := new(bytes.Buffer)
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
ms.AssertExpectations(GinkgoT())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
})
}) })
Context("ZipArtist", func() { Context("ZipArtist", func() {
@ -296,6 +315,30 @@ var _ = Describe("Archiver", func() {
}) })
Context("ZipPlaylist", func() { Context("ZipPlaylist", func() {
It("names the entries and the M3U lines after the resolved format", func() {
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
}}
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist").Return(plRepo)
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
out := new(bytes.Buffer)
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
m3u, err := zr.File[1].Open()
Expect(err).ToNot(HaveOccurred())
defer m3u.Close()
content, err := io.ReadAll(m3u)
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
})
It("zips a playlist correctly", func() { It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{ tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
@ -571,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
} }
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
type fakeDecider struct {
stream.TranscodeDecider
resolved *stream.Request
}
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
if f.resolved != nil {
return *f.resolved
}
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
}
type mockShare struct { type mockShare struct {
mock.Mock mock.Mock
core.Share core.Share

View file

@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
} }
} }
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if tracks == nil {
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
}
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil { if err != nil {
return resolution{}, err return resolution{}, err
} }

View file

@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{})) Expect(res).To(Equal(resolution{}))
}) })
It("returns an error when the playlist tracks cannot be loaded", func() {
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
}) })
}) })

View file

@ -4,9 +4,11 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"fmt"
"io" "io"
"math" "math"
"math/rand/v2" "math/rand/v2"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{} trace := &ChainTrace{}
ctx = withTrace(ctx, trace) ctx = withTrace(ctx, trace)
out, got, retryIn := w.proc.acquire(ctx, item) out, got, retryIn := w.safeAcquire(ctx, item)
queue := w.proc.ds.ArtworkQueue() queue := w.proc.ds.ArtworkQueue()
switch out { switch out {
@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got return out, got
} }
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent. // that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {

View file

@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id) return v.MockPlaylistRepo.Get(ctx, id)
} }
type panickingAlbumRepo struct {
*tests.MockAlbumRepo
panicID string
}
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
if id == r.panicID {
panic("boom")
}
return r.MockAlbumRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo { func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo() repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@ -278,6 +290,36 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
}) })
It("fails an item that panics, without stopping the rest of the batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
})
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(2))
it := findQueued(queueRepo, "al", "alboom")
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
Expect(it.Trace).To(ContainSubstring("boom"))
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() { It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external" conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})

View file

@ -53,7 +53,7 @@ func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) str
log.Info(ctx, "Creating new JWT secret", "key", key) log.Info(ctx, "Creating new JWT secret", "key", key)
return createNewSecret(ctx, ds, key) return createNewSecret(ctx, ds, key)
} }
if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil { if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
return createNewSecret(ctx, ds, key) return createNewSecret(ctx, ds, key)
} }
@ -171,12 +171,11 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
secret := id.NewRandom() secret := id.NewRandom()
encSecret, err := utils.Encrypt(ctx, EncryptionKey(), secret) encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
if err != nil { if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err) log.Error(ctx, "Could not encrypt JWT secret", err)
return secret return secret
} }
ctx = log.WithSecrets(ctx, encSecret)
if err := ds.Property().Put(ctx, key, encSecret); err != nil { if err := ds.Property().Put(ctx, key, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err) log.Error(ctx, "Could not save JWT secret in DB", err)
} }
@ -196,7 +195,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
return jwtauth.VerifyToken(TokenAuth, tokenStr) return jwtauth.VerifyToken(TokenAuth, tokenStr)
} }
func EncryptionKey() []byte { func getEncKey() []byte {
key := cmp.Or( key := cmp.Or(
conf.Server.PasswordEncryptionKey, conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey, consts.DefaultEncryptionKey,

View file

@ -15,7 +15,6 @@ import (
) )
func TestAuth(t *testing.T) { func TestAuth(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal) log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Auth Test Suite") RunSpecs(t, "Auth Test Suite")

View file

@ -1,54 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var ErrSetupComplete = errors.New("setup already complete")
// CreateFirstAdmin counts and inserts in one locked transaction, so racing setups cannot both win.
// then, if not nil, runs in that same transaction with the new user.
func CreateFirstAdmin(ctx context.Context, ds model.DataStore, username, password string, then func(tx model.DataStore, u *model.User) error) (*model.User, error) {
var created *model.User
err := ds.WithTxImmediate(func(tx model.DataStore) error {
count, err := tx.User().CountAll(ctx)
if err != nil {
return fmt.Errorf("counting users: %w", err)
}
if count > 0 {
return ErrSetupComplete
}
log.Warn(ctx, "Creating initial user", "user", username)
u := model.User{
ID: id.NewRandom(),
UserName: username,
Name: cases.Title(language.Und).String(username),
NewPassword: password,
IsAdmin: true,
LastLoginAt: new(time.Now()),
}
if err := tx.User().Put(ctx, &u); err != nil {
return fmt.Errorf("creating initial user: %w", err)
}
if created, err = tx.User().Get(ctx, u.ID); err != nil {
return err
}
if then != nil {
return then(tx, created)
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}

View file

@ -1,106 +0,0 @@
package auth_test
import (
"context"
"errors"
"path/filepath"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CreateFirstAdmin", Ordered, func() {
var ctx context.Context
var ds model.DataStore
BeforeAll(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "first-admin.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
DeferCleanup(db.Init(GinkgoT().Context()))
ds = persistence.New(db.Db())
})
BeforeEach(func() {
ctx = GinkgoT().Context()
_, err := db.Db().ExecContext(ctx, "delete from user")
Expect(err).ToNot(HaveOccurred())
})
create := func(name string) (*model.User, error) {
return auth.CreateFirstAdmin(ctx, ds, name, "secret", nil)
}
It("creates an admin with a title-cased name and returns it with its id", func() {
u, err := create("john")
Expect(err).ToNot(HaveOccurred())
Expect(u.ID).ToNot(BeEmpty())
Expect(u.IsAdmin).To(BeTrue())
Expect(u.Name).To(Equal("John"))
stored, err := ds.User().FindByUsernameWithPassword(ctx, "john")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Password).To(Equal("secret"))
})
It("refuses once any user exists", func() {
_, err := create("first")
Expect(err).ToNot(HaveOccurred())
_, err = create("second")
Expect(err).To(MatchError(auth.ErrSetupComplete))
})
It("runs then in the same transaction, rolling the user back when it fails", func() {
boom := errors.New("boom")
var seen string
_, err := auth.CreateFirstAdmin(ctx, ds, "john", "secret", func(tx model.DataStore, u *model.User) error {
seen = u.ID
Expect(tx.User().CountAll(ctx)).To(Equal(int64(1)))
return boom
})
Expect(err).To(MatchError(boom))
Expect(seen).ToNot(BeEmpty())
Expect(ds.User().CountAll(ctx)).To(BeZero())
})
It("lets exactly one of two concurrent setups win", func() {
var wg sync.WaitGroup
errs := make([]error, 2)
for i, name := range []string{"racer-a", "racer-b"} {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
_, errs[i] = auth.CreateFirstAdmin(ctx, slowCountDS{ds}, name, "secret", nil)
}()
}
wg.Wait()
Expect(errs).To(ContainElement(BeNil()))
Expect(errs).To(ContainElement(MatchError(auth.ErrSetupComplete)))
Expect(ds.User().CountAll(ctx)).To(Equal(int64(1)))
})
})
type slowCountDS struct{ model.DataStore }
func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} }
func (d slowCountDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
return d.DataStore.WithTxImmediate(func(tx model.DataStore) error { return block(slowCountDS{tx}) }, scope...)
}
type slowCountUsers struct{ model.UserRepository }
// Holds the transaction open after counting, so an unlocked count would interleave with the other racer.
func (u slowCountUsers) CountAll(ctx context.Context, opts ...model.QueryOptions) (int64, error) {
n, err := u.UserRepository.CountAll(ctx, opts...)
time.Sleep(50 * time.Millisecond)
return n, err
}

View file

@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"` MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
} }
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) { func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath) path, file := filepath.Split(filePath)
s, err := storage.For(path) s, err := storage.For(path)
@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
md := metadata.New(path, tag) md := metadata.New(scannerPath(lib, filePath), tag)
result := &InspectOutput{ result := &InspectOutput{
File: filePath, File: filePath,
RawTags: tags[file].Tags, RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(libraryId, folderId)), MappedTags: new(md.ToMediaFile(lib, folderId)),
} }
return result, nil return result, nil
} }
// scannerPath returns the path the scanner uses for the file (relative to its library), so
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
func scannerPath(lib model.Library, filePath string) string {
absPath, err := filepath.Abs(filePath)
if err != nil || lib.Path == "" {
return filePath
}
return model.LibraryRelativePath(lib.Path, absPath)
}

45
core/inspect_test.go Normal file
View file

@ -0,0 +1,45 @@
package core_test
import (
"path/filepath"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Inspect", func() {
var fixtures string
BeforeEach(func() {
var err error
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
Expect(err).ToNot(HaveOccurred())
})
It("maps the file with the library-relative path the scanner uses", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
Expect(out.MappedTags.LibraryID).To(Equal(2))
})
It("gives the same IDs for relative and absolute paths", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
})
It("keeps the given path for a file outside the library", func() {
filePath := filepath.Join(fixtures, "test.mp3")
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal(filePath))
})
})

View file

@ -2,10 +2,12 @@ package core
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -15,6 +17,7 @@ import (
"github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
@ -200,23 +203,22 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity
} }
pathChanged := originalLib.Path != lib.Path pathChanged := originalLib.Path != lib.Path
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
err = r.LibraryRepository.Put(ctx, lib, cols...) err = r.LibraryRepository.Put(ctx, lib, cols...)
if err != nil { if err != nil {
return r.mapError(err) return r.mapError(err)
} }
// Restart watcher and trigger scan if path was updated if pathChanged && r.watcher != nil {
if pathChanged { if err := r.watcher.Watch(ctx, lib); err != nil {
if r.watcher != nil { log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
} }
}
if r.scanner != nil { if (pathChanged || pidChanged) && r.scanner != nil {
go r.triggerScan(ctx, lib, "updated") go r.triggerScan(ctx, lib, "updated")
}
} }
// Send library refresh event to all clients // Send library refresh event to all clients
@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
} }
} }
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
validationErrors["pidAlbum"] = err.Error()
}
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
validationErrors["pidTrack"] = err.Error()
}
if len(validationErrors) > 0 { if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors} return &rest.ValidationError{Errors: validationErrors}
} }
@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
return nil return nil
} }
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
func updatesColumn(cols []string, col string) bool {
return len(cols) == 0 || slices.Contains(cols, col)
}
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
// Validate path format // Validate path format
if !filepath.IsAbs(library.Path) { if !filepath.IsAbs(library.Path) {
@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil return nil
} }
var scanWaitInterval = time.Second
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
// Runs in its own goroutine and outlives the HTTP request
ctx = context.WithoutCancel(ctx)
// A running scan loaded the libraries before this change, and would reject a new request
for {
status, err := r.scanner.Status(ctx)
if err != nil || !status.Scanning {
break
}
time.Sleep(scanWaitInterval)
}
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now() start := time.Now()
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full
if err != nil { if errors.Is(err, model.ErrAlreadyScanning) {
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
} else if err != nil {
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else { } else {
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))

View file

@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() {
}) })
}) })
Describe("PID validation", func() {
pidError := func(err error, field string) string {
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
return validationErr.Errors[field]
}
It("rejects an unknown attribute in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
})
It("rejects albumid in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
})
It("rejects an unknown attribute in the track PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
})
It("trims spaces", func() {
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
_, err := repo.Save(ctx, library)
Expect(err).ToNot(HaveOccurred())
Expect(library.PIDAlbum).To(Equal("folder"))
Expect(library.PIDTrack).To(BeEmpty())
})
})
Describe("Path Validation", func() { Describe("Path Validation", func() {
Context("Create operation", func() { Context("Create operation", func() {
It("fails when path is not absolute", func() { It("fails when path is not absolute", func() {
@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() {
}, "100ms", "10ms").Should(Equal(0)) }, "100ms", "10ms").Should(Equal(0))
}) })
It("triggers scan when updating the library PID config", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// A quick scan: the scanner itself rescans this library in full
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
})
It("does not trigger scan when the PID fields were not sent", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
It("waits for a running scan before triggering a new one", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
scanner.SetScanning(true)
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "200ms", "20ms").Should(Equal(0))
scanner.SetScanning(false)
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "3s", "20ms").Should(Equal(1))
})
It("does not trigger scan when library creation fails", func() { It("does not trigger scan when library creation fails", func() {
// Try to create library with invalid data (empty name) // Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir} library := &model.Library{Path: tempDir}

View file

@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil { if err != nil {
log.Trace(ctx, "Error reading radios count", err) log.Trace(ctx, "Error reading radios count", err)
} }
data.Library.Libraries, err = c.ds.Library().CountAll(ctx) libs, err := c.ds.Library().GetAll(ctx)
if err != nil { if err != nil {
log.Trace(ctx, "Error reading libraries count", err) log.Trace(ctx, "Error reading libraries", err)
}
data.Library.Libraries = int64(len(libs))
if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) {
data.Config.HasCustomPID = true
} }
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},

View file

@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
if err != nil { if err != nil {
return nil, err return nil, err
} }
matcher := newLibraryMatcher(libs) matcher := model.NewLibraryMatcher(libs)
lib, ok := matcher.findLibrary(dir) lib, ok := matcher.FindLibrary(dir)
if !ok { if !ok {
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
} }

View file

@ -1,13 +1,11 @@
package playlists package playlists
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"time" "time"
@ -156,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) {
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
} }
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// 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) {
lib, ok := lm.findLibrary(absolutePath)
if !ok {
return 0, ""
}
return lib.ID, filepath.Clean(lib.Path)
}
// findLibrary checks if the absolute path is under any of the library paths.
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
// 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], true
}
}
}
return model.Library{}, false
}
// 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 {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports. // pathResolver handles path resolution logic for playlist imports.
type pathResolver struct { type pathResolver struct {
matcher *libraryMatcher matcher *model.LibraryMatcher
} }
// newPathResolver creates a pathResolver with libraries loaded from the datastore. // newPathResolver creates a pathResolver with libraries loaded from the datastore.
@ -219,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
matcher := newLibraryMatcher(libs) matcher := model.NewLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil return &pathResolver{matcher: matcher}, nil
} }
@ -246,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu
// a pathResolution with the library information. Returns an invalid resolution if // a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library. // the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
libID, libPath := r.matcher.findLibraryForPath(absolutePath) lib, ok := r.matcher.FindLibrary(absolutePath)
if libID == 0 { if !ok {
return pathResolution{valid: false} return pathResolution{valid: false}
} }
return pathResolution{ return pathResolution{
absolutePath: absolutePath, absolutePath: absolutePath,
libraryPath: libPath, libraryPath: filepath.Clean(lib.Path),
libraryID: libID, libraryID: lib.ID,
valid: true, valid: true,
} }
} }
@ -288,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path // Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher. // and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
return "" return ""
@ -308,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
return "" return ""
} }
lib, ok := matcher.findLibrary(localPath) lib, ok := matcher.FindLibrary(localPath)
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return "" return ""

View file

@ -9,187 +9,6 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
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().GetAll(ctx)
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 _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo var mockLibRepo *tests.MockLibraryRepo

View file

@ -76,6 +76,17 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
} }
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
var _ fs.ReadLinkFS = (*localFS)(nil)
func (lfs *localFS) ReadLink(name string) (string, error) {
return fs.ReadLink(lfs.FS, name)
}
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
return fs.Lstat(lfs.FS, name)
}
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
res, err := lfs.extractor.Parse(path...) res, err := lfs.extractor.Parse(path...)
if err != nil { if err != nil {

View file

@ -1,23 +0,0 @@
-- +goose Up
-- +goose StatementBegin
create table api_grant (
id varchar not null primary key,
user_id varchar not null references user(id) on delete cascade,
name varchar not null,
client varchar not null,
client_version varchar not null default '',
scopes varchar not null default '',
provider varchar not null,
secret_hash varchar not null unique,
user_epoch integer not null default 0,
created_at datetime not null,
last_used_at datetime,
last_used_ip varchar not null default ''
);
create index api_grant_user_id on api_grant(user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
drop table api_grant;
-- +goose StatementEnd

View file

@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
alter table library add column pid_album varchar default '' not null;
alter table library add column pid_track varchar default '' not null;
alter table library add column scanned_pid_album varchar default '' not null;
alter table library add column scanned_pid_track varchar default '' not null;
-- Every library was scanned with the global PID config, so seed it as their scanned config.
-- This way the upgrade does not trigger a full rescan.
update library set
scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''),
scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), '');
delete from property where id in ('PIDAlbum', 'PIDTrack');
-- +goose StatementEnd
-- +goose Down
SELECT 1;

2
go.mod
View file

@ -40,7 +40,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oapi-codegen/runtime v1.7.0
github.com/onsi/ginkgo/v2 v2.33.0 github.com/onsi/ginkgo/v2 v2.33.0
github.com/onsi/gomega v1.44.0 github.com/onsi/gomega v1.44.0
github.com/pelletier/go-toml/v2 v2.4.3 github.com/pelletier/go-toml/v2 v2.4.3
@ -75,7 +74,6 @@ require (
require ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect

11
go.sum
View file

@ -6,18 +6,14 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
@ -140,7 +136,6 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@ -193,10 +188,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
@ -264,7 +255,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -273,7 +263,6 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View file

@ -72,10 +72,7 @@ const (
type contextKey string type contextKey string
const ( const loggerCtxKey = contextKey("logger")
loggerCtxKey = contextKey("logger")
secretsCtxKey = contextKey("secrets")
)
type levelPath struct { type levelPath struct {
path string path string
@ -191,34 +188,6 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
return ctx return ctx
} }
// Shorter values could match unrelated log text, or the [REDACTED] marker itself.
const minSecretLen = 8
// WithSecrets returns a context whose log entries have every occurrence of values replaced by
// [REDACTED], when redacting is enabled. Values shorter than minSecretLen are ignored.
func WithSecrets(ctx context.Context, values ...string) context.Context {
if ctx == nil {
ctx = context.Background()
}
secrets := slices.Clone(secretsFrom(ctx))
for _, v := range values {
if len(v) >= minSecretLen {
secrets = append(secrets, v)
}
}
// Longest first, so a secret containing another is not left partly visible.
slices.SortStableFunc(secrets, func(a, b string) int { return cmp.Compare(len(b), len(a)) })
return context.WithValue(ctx, secretsCtxKey, secrets)
}
func secretsFrom(ctx context.Context) []string {
if ctx == nil {
return nil
}
secrets, _ := ctx.Value(secretsCtxKey).([]string)
return secrets
}
// SetDefaultLogger swaps the process-wide logger and returns the previous one, // SetDefaultLogger swaps the process-wide logger and returns the previous one,
// so tests can restore the original (with its hooks and formatter) on cleanup. // so tests can restore the original (with its hooks and formatter) on cleanup.
func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { func SetDefaultLogger(l *logrus.Logger) *logrus.Logger {
@ -320,12 +289,6 @@ func parseArgs(args []any) (*logrus.Entry, string) {
if err != nil { if err != nil {
l = createNewLogger() l = createNewLogger()
} else { } else {
switch ctx := args[0].(type) {
case context.Context:
l = l.WithContext(ctx)
case *http.Request:
l = l.WithContext(ctx.Context())
}
args = args[1:] args = args[1:]
} }
} }

View file

@ -1,14 +1,11 @@
package log package log
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime"
"testing" "testing"
"time" "time"
@ -96,9 +93,9 @@ var _ = Describe("Logger", func() {
It("logs source file and line number, if requested", func() { It("logs source file and line number, if requested", func() {
SetLogSourceLine(true) SetLogSourceLine(true)
_, _, line, _ := runtime.Caller(0)
Error("A crash happened") Error("A crash happened")
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1))) // NOTE: This assertion breaks if the line number above changes
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96"))
Expect(hook.LastEntry().Message).To(Equal("A crash happened")) Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
}) })
@ -112,26 +109,6 @@ var _ = Describe("Logger", func() {
Error("Simple Message", "key1", t) Error("Simple Message", "key1", t)
Expect(hook.LastEntry().Data["key1"]).To(Equal("nil")) Expect(hook.LastEntry().Data["key1"]).To(Equal("nil"))
}) })
It("passes the call's context to hooks", func() {
ctx := WithSecrets(GinkgoT().Context(), "s3cr3t-value")
Error(ctx, "Simple Message")
Expect(hook.LastEntry().Context).To(Equal(ctx))
Error(httptest.NewRequest("get", "/", nil).WithContext(ctx), "Simple Message")
Expect(hook.LastEntry().Context).To(Equal(ctx))
})
It("redacts the context's secrets when redacting is on", func() {
l.AddHook(redacted)
ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t-value")
var buf bytes.Buffer
l.SetOutput(&buf)
Error(ctx, "Saving s3cr3t-value", "args", map[string]any{"value": "s3cr3t-value"})
Expect(buf.String()).ToNot(ContainSubstring("s3cr3t-value"))
Expect(buf.String()).To(ContainSubstring("user=admin"))
})
}) })
Describe("Levels", func() { Describe("Levels", func() {

View file

@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"regexp" "regexp"
"strings"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -36,7 +35,6 @@ func (h *Hook) Fire(e *logrus.Entry) error {
if err := h.initRedaction(); err != nil { if err := h.initRedaction(); err != nil {
return err return err
} }
redactSecrets(e)
for _, re := range h.redactionKeys { for _, re := range h.redactionKeys {
// Redact based on key matching in Data fields // Redact based on key matching in Data fields
for k, v := range e.Data { for k, v := range e.Data {
@ -49,8 +47,7 @@ func (h *Hook) Fire(e *logrus.Entry) error {
} }
switch reflect.TypeOf(v).Kind() { switch reflect.TypeOf(v).Kind() {
case reflect.String: case reflect.String:
// Via reflect: named string types (e.g. enums) have Kind String but fail v.(string). e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
e.Data[k] = re.ReplaceAllString(reflect.ValueOf(v).String(), "$1[REDACTED]$2")
continue continue
case reflect.Map: case reflect.Map:
s := fmt.Sprintf("%+v", v) s := fmt.Sprintf("%+v", v)
@ -66,36 +63,6 @@ func (h *Hook) Fire(e *logrus.Entry) error {
return nil return nil
} }
// redactSecrets hides the values marked with WithSecrets in the context the entry was logged with.
func redactSecrets(e *logrus.Entry) {
secrets := secretsFrom(e.Context)
if len(secrets) == 0 {
return
}
hide := func(s string) string {
for _, secret := range secrets {
s = strings.ReplaceAll(s, secret, "[REDACTED]")
}
return s
}
e.Message = hide(e.Message)
for k, v := range e.Data {
if v == nil {
continue
}
// fmt.Sprint renders like the text formatter and survives typed-nil errors; []byte is written raw.
var s string
if b, ok := v.([]byte); ok {
s = string(b)
} else {
s = fmt.Sprint(v)
}
if hidden := hide(s); hidden != s {
e.Data[k] = hidden
}
}
}
func (h *Hook) initRedaction() error { func (h *Hook) initRedaction() error {
if len(h.redactionKeys) == 0 { if len(h.redactionKeys) == 0 {
for _, redactionKey := range h.RedactionList { for _, redactionKey := range h.RedactionList {

View file

@ -1,8 +1,6 @@
package log package log
import ( import (
"errors"
"net/url"
"testing" "testing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -159,85 +157,3 @@ func TestEntryMessage(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message) assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message)
} }
type namedString string
func TestFireRedactsNamedStringTypes(t *testing.T) {
hook := &Hook{RedactionList: []string{"(secret=)[^&]+"}}
e := &logrus.Entry{Data: logrus.Fields{"code": namedString("not_found"), "url": namedString("/x?secret=abc")}}
assert.NotPanics(t, func() { _ = hook.Fire(e) })
assert.Equal(t, "not_found", e.Data["code"])
assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"])
}
func TestFireRedactsContextSecrets(t *testing.T) {
ctx := WithSecrets(t.Context(), "s3cr3t-value")
ctx = WithSecrets(ctx, "", "other-secret")
e := &logrus.Entry{
Context: ctx,
Message: "value s3cr3t-value in message",
Data: logrus.Fields{
"str": "has s3cr3t-value",
"named": namedString("named other-secret"),
"args": map[string]any{"p0": "s3cr3t-value", "p1": "plain"},
"error": errors.New("failed with other-secret"),
"num": 42,
"clean": namedString("untouched"),
},
}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "value [REDACTED] in message", e.Message)
assert.Equal(t, "has [REDACTED]", e.Data["str"])
assert.Equal(t, "named [REDACTED]", e.Data["named"])
assert.Equal(t, "map[p0:[REDACTED] p1:plain]", e.Data["args"])
assert.Equal(t, "failed with [REDACTED]", e.Data["error"])
assert.Equal(t, 42, e.Data["num"])
assert.Equal(t, namedString("untouched"), e.Data["clean"])
}
func TestFireRedactsContextSecretsInAnyValueType(t *testing.T) {
ctx := WithSecrets(t.Context(), "s3cr3t-value")
var nilErr *url.Error
e := &logrus.Entry{
Context: ctx,
Data: logrus.Fields{
"slice": []any{"s3cr3t-value", 1},
"struct": struct{ A string }{"s3cr3t-value"},
"bytes": []byte("has s3cr3t-value"),
"nilErr": nilErr,
},
}
assert.NotPanics(t, func() { _ = (&Hook{}).Fire(e) })
assert.Equal(t, "[[REDACTED] 1]", e.Data["slice"])
assert.Equal(t, "{[REDACTED]}", e.Data["struct"])
assert.Equal(t, "has [REDACTED]", e.Data["bytes"])
assert.Equal(t, nilErr, e.Data["nilErr"])
}
func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) {
args := map[string]any{"p0": "value"}
e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "value", e.Message)
assert.Equal(t, logrus.Fields{"str": "value", "args": args}, e.Data)
}
func TestFireRedactsLongerSecretsFirst(t *testing.T) {
ctx := WithSecrets(t.Context(), "abcdefgh", "abcdefghijkl")
e := &logrus.Entry{Context: ctx, Message: "abcdefghijkl"}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "[REDACTED]", e.Message)
}
func TestFireIgnoresShortSecrets(t *testing.T) {
ctx := WithSecrets(t.Context(), "abc")
e := &logrus.Entry{Context: ctx, Message: "abc in UPDATE ... abc"}
assert.Nil(t, (&Hook{}).Fire(e))
assert.Equal(t, "abc in UPDATE ... abc", e.Message)
}

View file

@ -37,7 +37,6 @@ type DataStore interface {
Plugin() PluginRepository Plugin() PluginRepository
Artwork() ArtworkRepository Artwork() ArtworkRepository
ArtworkQueue() ArtworkQueueRepository ArtworkQueue() ArtworkQueueRepository
Grant() GrantRepository
WithTx(block func(tx DataStore) error, scope ...string) error WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error

View file

@ -1,67 +0,0 @@
package model
import (
"context"
"database/sql/driver"
"fmt"
"strings"
"time"
)
type Grant struct {
ID string `structs:"id" json:"id"`
UserID string `structs:"user_id" json:"userId"`
Name string `structs:"name" json:"name"`
Client string `structs:"client" json:"client"`
ClientVersion string `structs:"client_version" json:"clientVersion"`
Scopes Scopes `structs:"scopes" json:"scopes"`
Provider string `structs:"provider" json:"provider"`
SecretHash string `structs:"secret_hash" json:"-"`
UserEpoch int `structs:"user_epoch" json:"-"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"`
LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"`
}
func (g Grant) LastActivity() time.Time {
if g.LastUsedAt != nil {
return *g.LastUsedAt
}
return g.CreatedAt
}
type Grants []Grant
// Scopes is stored as a single space-separated column.
type Scopes []string
func (s Scopes) Value() (driver.Value, error) {
return strings.Join(s, " "), nil
}
func (s *Scopes) Scan(src any) error {
switch v := src.(type) {
case string:
*s = strings.Fields(v)
case []byte:
*s = strings.Fields(string(v))
case nil:
*s = nil
default:
return fmt.Errorf("cannot scan %T into Scopes", src)
}
return nil
}
type GrantRepository interface {
Put(ctx context.Context, g *Grant) error
Get(ctx context.Context, id string) (*Grant, error)
FindBySecretHash(ctx context.Context, hash string) (*Grant, error)
GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error)
CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error)
DeleteForUser(ctx context.Context, userID, id string) error
DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error
SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error
Touch(ctx context.Context, id, ip string, at, notSince time.Time) error
DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error)
}

View file

@ -1,24 +0,0 @@
package model_test
import (
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Grant", func() {
Describe("LastActivity", func() {
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
It("is the creation time for a grant never used", func() {
Expect(model.Grant{CreatedAt: created}.LastActivity()).To(Equal(created))
})
It("is the last use once the grant was used", func() {
used := created.Add(time.Hour)
Expect(model.Grant{CreatedAt: created, LastUsedAt: &used}.LastActivity()).To(Equal(used))
})
})
})

View file

@ -1,10 +1,13 @@
package model package model
import ( import (
"cmp"
"context" "context"
"strings"
"time" "time"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
) )
@ -27,6 +30,38 @@ type Library struct {
TotalSize int64 `json:"totalSize" db:"total_size"` TotalSize int64 `json:"totalSize" db:"total_size"`
TotalDuration float64 `json:"totalDuration" db:"total_duration"` TotalDuration float64 `json:"totalDuration" db:"total_duration"`
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
PIDAlbum string `json:"pidAlbum" db:"pid_album"`
PIDTrack string `json:"pidTrack" db:"pid_track"`
ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"`
ScannedPIDTrack string `json:"-" db:"scanned_pid_track"`
}
// PIDConfig holds the persistent ID specs used to compute track and album IDs.
type PIDConfig struct {
Track string
Album string
}
// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to
// the global config.
func (l Library) EffectivePID() PIDConfig {
return PIDConfig{
Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track),
Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album),
}
}
// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished
// scan of this library. A library that was never scanned counts as changed.
func (l Library) PIDChanged() bool {
pid := l.EffectivePID()
return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track)
}
// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be
// rescanned in full. A library that never finished a scan has nothing to regroup.
func (l Library) NeedsPIDRescan() bool {
return !l.LastScanAt.IsZero() && l.PIDChanged()
} }
const ( const (
@ -59,6 +94,8 @@ type LibraryRepository interface {
// TODO These methods should be moved to a core service // TODO These methods should be moved to a core service
ScanBegin(ctx context.Context, id int, fullScan bool) error ScanBegin(ctx context.Context, id int, fullScan bool) error
ScanEnd(ctx context.Context, id int) error ScanEnd(ctx context.Context, id int) error
// SetScannedPID records the PID specs used by the last finished scan of the library
SetScannedPID(ctx context.Context, id int, pid PIDConfig) error
ScanInProgress(ctx context.Context) (bool, error) ScanInProgress(ctx context.Context) (bool, error)
RefreshStats(ctx context.Context, id int) error RefreshStats(ctx context.Context, id int) error
} }

57
model/library_matcher.go Normal file
View file

@ -0,0 +1,57 @@
package model
import (
"cmp"
"path/filepath"
"slices"
"strings"
)
// LibraryMatcher finds the library that contains an absolute path.
type LibraryMatcher struct {
libraries Libraries
cleanedPaths []string
}
// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music.
func NewLibraryMatcher(libs Libraries) *LibraryMatcher {
libs = slices.Clone(libs)
slices.SortFunc(libs, func(i, j Library) int {
return cmp.Compare(len(j.Path), len(i.Path))
})
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths}
}
// FindLibrary returns the library whose path contains absolutePath.
func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) {
for i, libPath := range lm.cleanedPaths {
// A cleaned path only ends with a separator when it is a filesystem root
if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) ||
absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) {
return lm.libraries[i], true
}
}
return Library{}, false
}
// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it
// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged.
func LibraryRelativePath(libPath, path string) string {
if !filepath.IsAbs(path) {
return path
}
// The library root may be relative (e.g. the default "./music"); it resolves against the same cwd
absLib, err := filepath.Abs(libPath)
if err != nil {
return path
}
rel, err := filepath.Rel(absLib, path)
if err != nil || !filepath.IsLocal(rel) {
return path
}
return filepath.ToSlash(rel)
}

View file

@ -0,0 +1,91 @@
package model_test
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LibraryMatcher", func() {
// Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does
find := func(libs model.Libraries, path string) int {
for i := range libs {
libs[i].Path = filepath.FromSlash(libs[i].Path)
}
lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path))
if !ok {
return 0
}
return lib.ID
}
DescribeTable("matches the longest library path",
func(libs model.Libraries, path string, expectedID int) {
Expect(find(libs, path)).To(Equal(expectedID))
},
Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3),
Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2),
Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1),
Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2),
Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3),
Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2),
Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1),
Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1),
Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1),
Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2),
)
It("does not match a path outside every library", func() {
Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero())
})
It("does not match anything without libraries", func() {
Expect(find(nil, "/music/track.mp3")).To(BeZero())
})
It("does not reorder the caller's libraries", func() {
libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}}
model.NewLibraryMatcher(libs)
Expect(libs.IDs()).To(Equal([]int{1, 2}))
})
})
var _ = Describe("LibraryRelativePath", func() {
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
// (a Unix-style "/foo" is not absolute on Windows).
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
It("returns a relative path unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
})
It("rebases an absolute target when the library root is relative", func() {
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
})
It("rebases an absolute path that equals the library root to '.'", func() {
Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal("."))
})
It("rebases an absolute path under the library root", func() {
Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("handles a trailing slash on the library path", func() {
Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("leaves an absolute path outside the library root unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside))
})
It("returns an empty path unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, "")).To(Equal(""))
})
})

73
model/library_test.go Normal file
View file

@ -0,0 +1,73 @@
package model_test
import (
"encoding/json"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library PID config", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "global_album"
conf.Server.PID.Track = "global_track"
})
Describe("EffectivePID", func() {
It("falls back to the global config", func() {
Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"}))
})
It("uses the library overrides", func() {
lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"}
Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"}))
})
})
Describe("PIDChanged", func() {
It("is false when the scanned specs match, ignoring case", func() {
lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeFalse())
})
It("is true when the album override differs from the scanned spec", func() {
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true when only the track spec changed", func() {
lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true when the global config changed for a library without overrides", func() {
lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true for a library that was never scanned", func() {
Expect(model.Library{}.PIDChanged()).To(BeTrue())
})
})
Describe("NeedsPIDRescan", func() {
It("is false for a library that never finished a scan", func() {
Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse())
})
It("is true for a scanned library whose PID config changed", func() {
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
Expect(lib.NeedsPIDRescan()).To(BeTrue())
})
It("is false for a scanned library whose PID config did not change", func() {
lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
Expect(lib.NeedsPIDRescan()).To(BeFalse())
})
})
It("does not expose the scanned specs in JSON", func() {
data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"})
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`))
Expect(string(data)).ToNot(ContainSubstring("secret_"))
})
})

View file

@ -8,15 +8,14 @@ import (
"math" "math"
"strconv" "strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str" "github.com/navidrome/navidrome/utils/str"
) )
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
mf := model.MediaFile{ mf := model.MediaFile{
LibraryID: libID, LibraryID: lib.ID,
FolderID: folderID, FolderID: folderID,
Tags: maps.Clone(md.tags), Tags: maps.Clone(md.tags),
} }
@ -84,8 +83,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs // Persistent IDs
mf.PID = md.trackPID(mf) pid := lib.EffectivePID()
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) mf.PID = md.trackPID(mf, pid)
mf.AlbumID = md.albumID(mf, pid.Album)
// BFR These IDs will go away once the UI handle multiple participants. // BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers // BFR For Legacy Subsonic compatibility, we will set them in the API handlers

View file

@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile { var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags props.Tags = tags
md = metadata.New("filepath", props) md = metadata.New("filepath", props)
return md.ToMediaFile(1, "folderID") return md.ToMediaFile(model.Library{ID: 1}, "folderID")
} }
Describe("Persistent IDs", func() {
It("uses the library PID config for the album ID and for albumid in the track spec", func() {
props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}}
md = metadata.New("Jazz/Loose/01.mp3", props)
byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID")
byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID")
Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID))
Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder")))
Expect(byFolder.PID).ToNot(Equal(byTags.PID))
})
})
Describe("Dates", func() { Describe("Dates", func() {
It("should parse properly tagged dates ", func() { It("should parse properly tagged dates ", func() {
mf = toMediaFile(model.RawTags{ mf = toMediaFile(model.RawTags{

View file

@ -38,7 +38,7 @@ var _ = Describe("Participants", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile { var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags props.Tags = tags
md = metadata.New("filepath", props) md = metadata.New("filepath", props)
return md.ToMediaFile(1, "folderID") return md.ToMediaFile(model.Library{ID: 1}, "folderID")
} }
Describe("ARTIST(S) tags", func() { Describe("ARTIST(S) tags", func() {

View file

@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() {
tag: {tagValue}, tag: {tagValue},
} }
md = metadata.New(filePath, props) md = metadata.New(filePath, props)
return md.ToMediaFile(0, "0") return md.ToMediaFile(model.Library{}, "0")
} }
DescribeTable("Gain", DescribeTable("Gain",

View file

@ -2,11 +2,11 @@ package metadata
import ( import (
"cmp" "cmp"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@ -22,12 +22,13 @@ type hashFunc = func(...string) string
// attributes. Attributes can be either tags or processed values like folder, // attributes. Attributes can be either tags or processed values like folder,
// albumid, albumartistid, etc. For each field, it gets all its attribute values // albumid, albumartistid, etc. For each field, it gets all its attribute values
// and concatenates them, then hashes the result. If a field is empty, it is // and concatenates them, then hashes the result. If a field is empty, it is
// skipped and the function looks for the next field. // skipped and the function looks for the next field. albumSpec is the album PID
// spec used to resolve the `albumid` attribute.
// //
// Taking hash as a parameter (instead of closing over it in a factory) keeps // Taking hash as a parameter (instead of closing over it in a factory) keeps
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the // mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
// heap on every call. // heap on every call.
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string {
switch spec { switch spec {
case "track_legacy": case "track_legacy":
return legacyTrackID(mf, prependLibId) return legacyTrackID(mf, prependLibId)
@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
values := make([]string, len(attributes)) values := make([]string, len(attributes))
hasValue := false hasValue := false
for i, attr := range attributes { for i, attr := range attributes {
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
if v != "" { if v != "" {
hasValue = true hasValue = true
} }
@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
return hash(pid) return hash(pid)
} }
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr)) attr = strings.TrimSpace(strings.ToLower(attr))
switch attr { switch attr {
case "albumid": case "albumid":
if spec == conf.Server.PID.Album { if spec == albumSpec {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return "" return ""
} }
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
case "folder": case "folder":
return filepath.Dir(mf.Path) return filepath.Dir(mf.Path)
case "albumartistid": case "albumartistid":
@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool,
return md.String(model.TagName(attr)) return md.String(model.TagName(attr))
} }
func (md Metadata) trackPID(mf model.MediaFile) string { // ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config".
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) // Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them.
func ValidatePIDSpec(spec string, isAlbum bool) error {
switch {
case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy":
return nil
}
for field := range strings.SplitSeq(spec, "|") {
for attr := range strings.SplitSeq(field, ",") {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "":
return fmt.Errorf("empty attribute in %q", spec)
case "albumid":
if isAlbum {
return errors.New("albumid cannot be used in an album PID")
}
case "folder", "albumartistid":
default:
name, ok := model.CanonicalTagName(attr)
if !ok {
return fmt.Errorf("unknown attribute %q", attr)
}
if isAlbum && string(name) != attr {
return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr)
}
}
}
}
return nil
}
func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string {
return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash)
} }
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return computePID(mf, md, pidConf, true, id.NewHash) return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
} }
// BFR Must be configurable? // BFR Must be configurable?
func (md Metadata) artistID(name string) string { func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name} mf := model.MediaFile{AlbumArtist: name}
return computePID(mf, md, "albumartistid", false, id.NewHash) return computePID(mf, md, "albumartistid", "", false, id.NewHash)
} }
func (md Metadata) mapTrackTitle() string { func (md Metadata) mapTrackTitle() string {

View file

@ -3,8 +3,7 @@ package metadata
import ( import (
"strings" "strings"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -13,16 +12,18 @@ import (
var _ = Describe("getPID", func() { var _ = Describe("getPID", func() {
var ( var (
md Metadata md Metadata
mf model.MediaFile mf model.MediaFile
sum hashFunc sum hashFunc
albumSpec string
) )
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
return computePID(mf, md, spec, prependLibId, sum) return computePID(mf, md, spec, albumSpec, prependLibId, sum)
} }
BeforeEach(func() { BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
albumSpec = consts.DefaultAlbumPID
}) })
Context("attributes are tags", func() { Context("attributes are tags", func() {
@ -66,8 +67,7 @@ var _ = Describe("getPID", func() {
Context("calculated attributes", func() { Context("calculated attributes", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
}) })
When("field is title", func() { When("field is title", func() {
It("should return the pid", func() { It("should return the pid", func() {
@ -121,8 +121,8 @@ var _ = Describe("getPID", func() {
When("albumid configuration refers to albumid recursively", func() { When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() { It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920 // Reproduce the issue from #4920
conf.Server.PID.Album = "albumid,album,albumversion,releasedate" albumSpec = "albumid,album,albumversion,releasedate"
spec := conf.Server.PID.Album spec := albumSpec
md.tags = map[model.TagName][]string{ md.tags = map[model.TagName][]string{
"album": {"Album Name"}, "album": {"Album Name"},
"albumversion": {"Version"}, "albumversion": {"Version"},
@ -205,8 +205,7 @@ var _ = Describe("getPID", func() {
}) })
When("prependLibId is true with nested albumid", func() { When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() { It("should handle nested albumid calls correctly", func() {
DeferCleanup(configtest.SetupConfig()) albumSpec = "album"
conf.Server.PID.Album = "album"
spec := "albumid" spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}} md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist" mf.AlbumArtist = "Test Artist"
@ -306,3 +305,34 @@ var _ = Describe("getPID", func() {
}) })
}) })
}) })
var _ = Describe("ValidatePIDSpec", func() {
DescribeTable("accepts valid specs",
func(spec string, isAlbum bool) {
Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed())
},
Entry("empty, meaning the global config", "", true),
Entry("default album spec", consts.DefaultAlbumPID, true),
Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false),
Entry("folder", "folder", true),
Entry("album legacy", "album_legacy", true),
Entry("track legacy", "track_legacy", false),
Entry("computed attributes", "albumartistid,album|title", true),
Entry("albumid in a track spec", "albumid,title", false),
Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true),
)
DescribeTable("rejects invalid specs",
func(spec string, isAlbum bool, msg string) {
Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg)))
},
Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`),
Entry("empty field", "album||title", true, "empty attribute"),
Entry("empty attribute", "album,,title", true, "empty attribute"),
Entry("trailing separator", "album|", true, "empty attribute"),
Entry("albumid in an album spec", "albumid,album", true, "albumid"),
Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`),
Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`),
Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`),
)
})

View file

@ -2,12 +2,15 @@ package model
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
var ErrAlreadyScanning = errors.New("already scanning")
// ScanTarget represents a specific folder within a library to be scanned. // ScanTarget represents a specific folder within a library to be scanned.
// NOTE: This struct is used as a map key, so it should only contain comparable types. // NOTE: This struct is used as a map key, so it should only contain comparable types.
type ScanTarget struct { type ScanTarget struct {

View file

@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf {
return mappings return mappings
} }
// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name.
func CanonicalTagName(name string) (TagName, bool) {
tagName, ok := tagNameIndex()[TagName(name).ToLower()]
return tagName, ok
}
// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases
// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid).
var tagNameIndex = sync.OnceValue(func() map[TagName]TagName {
mappings := TagMappings()
index := make(map[TagName]TagName, len(mappings))
for name, tag := range mappings {
for _, alias := range tag.Aliases {
index[TagName(alias)] = name
}
}
for name := range mappings {
index[name] = name
}
return index
})
func TagRolesConf() TagConf { func TagRolesConf() TagConf {
_, cfg := parseMappings() _, cfg := parseMappings()
return cfg.Roles return cfg.Roles

View file

@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() {
}) })
}) })
}) })
var _ = Describe("CanonicalTagName", func() {
DescribeTable("resolves tag names and aliases",
func(name string, expected TagName) {
tagName, ok := CanonicalTagName(name)
Expect(ok).To(BeTrue())
Expect(tagName).To(Equal(expected))
},
Entry("tag name", "album", TagAlbum),
Entry("alias", "talb", TagAlbum),
Entry("mixed case alias", "TALB", TagAlbum),
Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID),
)
It("does not resolve an unknown name", func() {
_, ok := CanonicalTagName("nosuchtag")
Expect(ok).To(BeFalse())
})
})

View file

@ -1,114 +0,0 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
type grantRepository struct {
sqlRepository
}
func NewGrantRepository(db dbx.Builder) model.GrantRepository {
r := &grantRepository{}
r.db = db
r.tableName = "api_grant"
return r
}
const grantLastActivity = "COALESCE(last_used_at, created_at)"
func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error {
if g.ID == "" {
g.ID = id.NewRandom()
}
if g.CreatedAt.IsZero() {
g.CreatedAt = time.Now()
}
// Stored as UTC: SQLite compares these timestamps as strings.
g.CreatedAt = g.CreatedAt.UTC()
if g.LastUsedAt != nil {
t := g.LastUsedAt.UTC()
g.LastUsedAt = &t
}
values, err := toSQLArgs(*g)
if err != nil {
return err
}
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
return err
}
func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) {
return r.findOne(ctx, Eq{"id": id})
}
func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
return r.findOne(ctx, Eq{"secret_hash": hash})
}
func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) {
var g model.Grant
if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil {
return nil, err
}
return &g, nil
}
func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer {
return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())}
}
func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) {
sel := r.newSelect(ctx).Columns("*").Where(activeForUser(userID, epoch, idleSince)).
OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id").
Offset(uint64(offset)).Limit(uint64(limit))
var res model.Grants
err := r.queryAll(ctx, sel, &res)
return res, err
}
func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) {
return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince)))
}
func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error {
n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID}))
if err != nil {
return err
}
if n == 0 {
return model.ErrNotFound
}
return nil
}
func (r *grantRepository) DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error {
return r.delete(ctx, And{Eq{"user_id": userID}, Lt{"user_epoch": currentEpoch}})
}
// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back.
func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error {
cond := Eq{"user_id": userID, "user_epoch": fromEpoch}
if onlyID != "" {
cond["id"] = onlyID
}
_, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond))
return err
}
func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error {
upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip).
Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}})
_, err := r.executeSQL(ctx, upd)
return err
}
func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC())))
}

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