Compare commits

..
Author SHA1 Message Date
Deluan Quintãoandnavidrome-bot d9d38f842a fix(ui): update German, Greek, Finnish, Galician, Polish, Portuguese (BR), Thai, Ukrainian, Chinese (traditional) translations from POEditor (#5833)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-09-12 00:52:54 -04:00
Deluan Quintão d0d5403708 feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069)
* feat(db): add repair command to rebuild a corrupted FTS5 search index

A corrupted media_file_fts index made every scan fail with 'database disk
image is malformed', and sqlite3's built-in 'rebuild' command cannot repair
contentless FTS5 tables, leaving users to hand-drop tables and triggers.

Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the
reported corruption is confined to the FTS5 search tables, drops and
recreates the three tables and their nine triggers and repopulates them
from the base tables (which hold all the data, so nothing is lost). The
result is verified with the FTS5-native 'integrity-check' command, which
reads only the rebuilt indexes instead of re-scanning the whole database
(on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second
verify). A --rebuild flag forces the rebuild even when the check passes,
for silently desynced indexes. The rebuild refuses to run while migrations
are pending, and a schema-comparison test guards the duplicated DDL against
drifting from the migration.

The DbPath existence check and the YES confirmation prompt, previously
copy-pasted across the backup commands, are extracted into shared cmd
helpers used by both backup and repair.

Part of #6067

* fix(db): type the FTS migration version as int64 for 32-bit builds

The untyped constant defaults to int, which overflows on arm/v7 and 386.

* feat(db): split repair into 'db doctor' and 'search rebuild' commands

A single 'db repair' command promised more than it delivered: the only thing
it could actually repair was the search index, and its diagnosis and its fix
were welded together, so a forced rebuild paid the full integrity check twice.

Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA
integrity_check and PRAGMA foreign_key_check, and routes the user (to
'search rebuild' when corruption is FTS-only, to backup/.recover otherwise).
'navidrome search rebuild' just rebuilds and verifies the FTS index, which
takes ~2s on a prod-size library instead of ~19s.

* refactor(cmd): extract a testable doctor function and bound foreign key output

Extract the doctor routing (check, classify, advise) into a function that
takes an io.Writer, so the advice paths are unit-tested and the process exit
happens in the cobra wrapper after the DB is closed (os.Exit was skipping the
deferred close, leaving WAL/SHM files behind on the unhealthy paths).

Aggregate foreign_key_check by (table, parent): the raw pragma emits one row
per orphan, which is unbounded output on a large corrupted library. Also
make confirmYES take an io.Reader, drop the unused return from the renamed
requireExistingDB, share the FTS table list with the tests, and stop the
schema-guard specs from paying for a seeded database they never use.

* docs(cmd): promise 'never alters your data' instead of 'never modifies the database'

Closing the doctor's connection can checkpoint a stale WAL into the main
file (as any SQLite tool does), so the byte-level claim was too strong. The
checks themselves are read-only and no logical content ever changes.

* fix(cmd): make 'db doctor' advice honest when checks are inconclusive

PRAGMA integrity_check stops at 100 errors and emits no marker row, so a
saturated result was being read as the whole picture. IntegrityCheck now sets
the limit itself and reports saturation as a truncated list, and doctor no
longer claims corruption is limited to the search index in that case.

Foreign key violations now print a next step instead of only flipping the
exit code: migrations run with foreign_keys off, so orphan rows are a
realistic leftover on a database that is not corrupt.

Also corrects the 'search rebuild' help, which promised that 'db doctor'
detects when a rebuild is needed -- integrity_check cannot see an index that
is merely out of sync; gives the never-migrated case its intended message
instead of a raw 'no such table: goose_db_version'; and extracts
rebuildSearchIndex so the database is closed before log.Fatal exits.

* refactor(cmd): promote 'db doctor' to a top-level 'doctor' command

The 'db' group held a single subcommand, and the checks planned for it reach
past the database: config, music folder permissions, external tools. None of
those belong under 'db'.

Promoting it also evens out the shape of the pair. The command that finds the
problem is now top-level alongside 'search rebuild', the command that fixes
it, matching the 'brew doctor' convention users already expect.

'db doctor' has never been released, so no alias or deprecation is needed.

* refactor(db): tighten the doctor and search rebuild internals

Follow-up cleanup with no behaviour change except where noted.

integrity_check now asks the pragma for one row beyond the reported limit and
treats that extra row as the proof it truncated, instead of inferring truncation
from a saturated count. That distinguishes a list of exactly 100 issues from one
that was cut short -- the old test could not, and 100 was SQLite's own default,
so passing it was a no-op.

ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving
the prose to the layer that already owns the CLI vocabulary. The goose table
probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database
ever been migrated' has one spelling. Also folds ftsMigrationApplied into
requireFTSMigration, lifts printFindings out of a closure that captured nothing,
names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the
drift test compares against the full migration chain, not the single frozen
migration it claimed.

* fix(db): verify the rebuilt search index before committing it

RebuildFTS committed its transaction and only then ran the FTS5 integrity
check, from the caller. A rebuild that produced a bad index was therefore
already persisted by the time anyone noticed, leaving the user worse off than
before they ran the command.

The check now runs inside the transaction, so a rebuild that does not verify
rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB
signature for callers outside a transaction; the shared body takes the small
execer interface that both *sql.DB and *sql.Tx satisfy.

Adds a spec for the rollback: it removes a column the repopulating SELECT
reads, so the transaction fails after the drops, and asserts the old index
still answers queries.

* refactor(cmd): drop the unused io.Reader parameter from confirmYES

The reader was added as a test seam that no test ever used: all three callers
pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused
os import from backup.go and search.go.

* fix(cmd): stop promising a scan clears every foreign key violation

doctor told the user to run 'navidrome scan -f' for any foreign key
violation. SQLStore.GC only purges albums, artists, folders, annotations,
bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the
next doctor run still reports them. player.user_id references user(id) and no
scan phase touches that table at all.

The advice now says a scan clears some of them and the rest have to be removed
by hand, which keeps the next step the earlier round asked for without claiming
a cleanup that does not happen.

* docs(db): trim over-long comments on the doctor and rebuild paths

Six comments ran past two lines or repeated something already stated nearby.
The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which
the new 'verifies before committing' sentence already implies, and a spec
comment restated that same rationale a second time.

* docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
Huang-404-QandDeluan 9e950cb63d fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085)
* fix(db): fail restore when the backup file does not exist instead of wiping the database

`navidrome backup restore -b <file>` passed the flag value straight to the
SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If
the file was not found (for example a file name relative to the working
directory instead of the backup directory), the driver silently created an
empty database and the backup API copied that emptiness over the live
database, reporting 'Restore complete' with an empty instance afterwards.

Two changes:

- db.Restore now opens the backup file read-only, so a missing file is an
  error and nothing gets created or overwritten.
- A relative --backup-file is resolved against Backup.Path, the same folder
  'backup create' writes to; absolute paths keep working as before.

Fixes #6083

* fix(db): stat the backup file instead of opening it read-only

The read-only DSN added in the previous commit works for the reported case but
breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the
path and a '%' sequence is percent-decoded, and a read-only open of a WAL
database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then
matched the unanchored prune regex, so 'backup prune -k 3' right after a restore
deleted real backups and kept one.

Stat the file before opening it and keep passing the plain path to the driver.
Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and
would otherwise open (and create) a different file. The prune regex is anchored
so sidecars are never counted as backups.

Also fixes the restore/backup/prune error logs, which printed BasePath (the web
URL prefix) instead of the backup location.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-11 20:50:45 -04:00
Deluan Quintão 2dc0983629 fix: miscellaneous fixes for shares, artwork resize, auth limits, and watcher start (#6098)
* fix(artwork): cap declared image dimensions before resizing

resizeStaticImage decoded the image with a raw image.Decode, so a small file declaring huge dimensions (e.g. a PNG header claiming 50k x 50k) forced a multi-gigabyte allocation on the serve-time resize path. The processor already guards its own decodes with decodeCapped; use it here too so the same 64M pixel cap applies to uploaded and sidecar images served through the cache.

* fix(share): validate every resource ID and reject mixed types when saving

Save only resolved the first ID in ResourceIDs to pick the resource type; the remaining IDs were never checked. A non-existent or hidden entity could ride along behind a valid first ID, and IDs of different kinds were accepted as one share. Resolve every ID as the current user and require all of them to be the same kind, returning ErrNotFound or ErrValidation otherwise.

* fix(share): scope album and media file shares to the owner's libraries

loadMedia already loaded artist and playlist shares as the share owner, but album and media_file shares used the repository context. Public share rendering carries no user, so the library filter was skipped and the share listed albums and tracks from libraries the owner cannot access. Streaming was already blocked, so only metadata leaked. Use ownerContext for all resource types.

* fix(server): limit login payload size and surface first-admin creation errors

The unauthenticated /login and /createAdmin handlers decoded the request body
with no size limit. Add a body-limit middleware to the /auth route group that
caps the payload at 8KiB, which is plenty for a username and password. Also
make createAdminUser return the datastore error instead of logging it and
returning nil, which previously let createAdmin proceed to a login attempt for
a user that was never saved.

* fix(conf): create the log file readable only by the owner

The log file was created with mode 0644, so other local users could read it. Logs can contain usernames, paths and, at trace level, request details, so create it with 0600 instead. Existing files keep their current mode.

* fix(lastfm): stop logging the auth token when fetching the session key fails

The Last.fm callback token was written to the log as a structured field on failure. The redaction hook only matches value patterns, so it was not masked. Drop the field; the request ID is enough to correlate the failure.

* fix(db): allow a music folder path containing a single quote on fresh databases

The library table migration interpolated conf.Server.MusicFolder into the SQL with fmt.Sprintf, so a path such as /music/Rock 'n' Roll produced invalid SQL and the migration failed on a brand new database. Bind the path as a parameter instead.

* fix(scanner): return an error when the folder watcher cannot start

When notify.Watch failed, the watcher goroutine logged the error and exited, but never signalled the started channel, so Start blocked until its context was cancelled and left the watching flag set. Call notify.Watch before spawning the event loop, so Start returns the error right away, the started/failed signalling goes away, and the storage can be watched again later.

* fix(jellyfin): limit the login request body size

The Jellyfin AuthenticateByName endpoint decoded its JSON body with no size limit, the same gap the native /auth routes had. Export the login body-limit middleware from the server package and apply it to the Jellyfin login route, before the optional per-IP rate limiter, so both unauthenticated login surfaces share the same 8KiB cap.

* fix(scanner): share one scanner instance across all injectors

Each wire injector built its own scanner controller, so the Subsonic and native API routers held a different instance from the ones used by the startup scan, the periodic scan, the folder watcher and the SIGUSR1 handler. Status reads the in-progress file and folder counters from its own instance, so getScanStatus reported scanning=true with count=0 for every scan not started through the API. Verified live with a startup scan: master reports count 0 while scanning, this branch reports the real counts. Expose the controller through a singleton, as the watcher, broker and play tracker already are, and wire everything to it. New stays available for tests that need isolated controllers.

* fix(share): do not panic when a media file share has no visible tracks

Share.CoverArtID picked a random track for media file shares without checking that any track was loaded. The tracks are empty when the files went missing, were deleted, or the owner lost access to their library, and the public share page then panicked inside the random pick and returned a 500. Return an empty artwork ID instead, so the page renders with the placeholder cover. The old guard on the split resource IDs was dead code, since SplitN always returns at least one element.
2026-09-11 15:03:54 -04:00
YorkandDeluan Quintão 2aa7a5c466 fix(ui): prevent Safari album grid resize when top menus open (#6125)
* fix(ui): prevent Safari album grid resize when top menus open

* fix(ui): disable scroll lock for all popovers

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-10 20:48:33 -04:00
Deluan Quintão 4067e36a06 Merge pull request #6126 from navidrome/t3code/update-go-dependencies
chore(deps): update direct Go dependencies and taglib fork
2026-09-10 15:08:21 -04:00
Deluan 964d3c778b build(deps): update direct Go dependencies and taglib fork
Bumps all 13 direct dependencies that had newer releases, plus the
go-taglib fork pin. No source changes were needed.

The jwx bump to v3.3.0 carries a security fix (GHSA-4cf7-xm37-g63h):
custom claim, header and JWK names were written unescaped, so a name
containing a quote could inject extra members. Navidrome is not
affected - every claim name we emit is a hardcoded literal - but the
fix is worth taking. cascadia v1.3.5 similarly limits selector nesting
to avoid a stack overflow, and our only selector is a constant.

go-sqlite3 v1.14.52 is the only bump with real behavior change: it
flushes the statement cache on schema changes, steps cached statements
eagerly, and drops the per-row goroutine used for query cancellation.
goose v3.28.0 raises its minimum to Go 1.26 and otherwise only touches
MySQL, ClickHouse and Azure SQL, which we do not use. The golang.org/x
bumps are routine. govulncheck reports no reachable vulnerabilities.

The taglib fork pin picks up two fixes. Audio properties are now
clamped with std::max(0, ...) before the unsigned conversion, so a
malformed file no longer reports a duration of ~49 days; this ports
upstream sentriz/go-taglib 0524e91 and additionally covers
bitsPerSample, which is specific to this fork. Bit depth is also now
reported for DSDIFF, TrueAudio and Shorten, which previously returned
0. Both values reach media_file only on re-extraction, so existing
libraries need a full scan to pick them up.
2026-09-10 15:00:01 -04:00
Deluan Quintão c14b598a01 Merge pull request #6124 from navidrome/fix/login-rate-limit-ip-spoofing
fix(server): key the login rate limit on a trust-aware client IP
2026-09-10 13:29:02 -04:00
Deluan Quintão 25e7b5b20d Merge branch 'master' into fix/login-rate-limit-ip-spoofing 2026-09-10 13:28:00 -04:00
Deluan Quintão bb386b13bf Merge pull request #6105 from navidrome/fix-forceformat-directplay
fix(transcoding): don't re-encode a source already in the forced format, and make piped FLAC seekable
2026-09-10 13:27:08 -04:00
Deluan Quintão 08eb46c8ad Merge branch 'master' into fix-forceformat-directplay 2026-09-10 13:26:45 -04:00
Deluan 055fbde3cf fix(server): key the login rate limit on a trust-aware client IP
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP
and X-Forwarded-For headers on every request, including when no trusted
reverse proxy was configured. The login rate limiters on /auth/login and the
Jellyfin /Users/AuthenticateByName derived their bucket from that value, so
an unauthenticated client could rotate a forwarding header and get a fresh
bucket for every password attempt, defeating the brute-force protection.

Resolve the client IP with chi's ClientIPFrom* middlewares instead. The
forwarding headers are only honoured when ExtAuth.TrustedSources is set and
the connecting peer is in that list, reusing the trust check that external
authentication already applies; otherwise the peer address is used. The
X-Forwarded-For chain is now walked against the trusted CIDRs rather than
taking its leftmost entry, so a spoofed value prepended by the client is
skipped.

Both limiters now key on the resolved address. The resolved address is still
mirrored into RemoteAddr, so request logging, player registration and the
Jellyfin local-network check keep reporting the client rather than the proxy.

Reported by gehan-psbc.
2026-09-10 08:59:01 -04:00
Deluan Quintão 72975a95fb fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare

The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.

createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.

updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.

* refactor(subsonic): trim the share downloadable lookup and align with the UI

updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.

createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.

The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.

* fix(subsonic): keep the share description on a downloadable-only update

updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.

This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.

Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
Deluan Quintão e7b449b805 Merge branch 'master' into fix-forceformat-directplay 2026-09-09 17:38:56 -04:00
Deluan 8d77a49b31 fix(ui): allow setting a transcoding Default Bit Rate of 0
The Default Bit Rate dropdown on the Transcoding create/edit forms was fed
BITRATE_CHOICES, which starts at 32. There was no way to pick 0, and the
SelectInput was not resettable, so an admin could neither create nor restore a
transcoding with no default bit rate, such as the default FLAC one (seeded with
0 in consts.DefaultTranscodings). Editing that row also rendered a blank
dropdown, since its stored value matched no choice.

Adds TRANSCODING_BITRATE_CHOICES, which prepends a 0 entry labelled 'None' to
the shared list. The forms use it as SelectInput choices, and the list and
read-only show view render it through SelectField, so all four screens resolve
the label from the same array and cannot drift. The shared BITRATE_CHOICES is
left untouched, because 0 is not a meaningful option for the player Max. Bit
Rate or the share dialog.

Reported in discussion #6107, where a user had deleted the default
transcodings and could not recreate the FLAC one.
2026-09-09 17:35:03 -04:00
MIguel LopesandDeluan Quintão 02c9816aec build(docker): add curl to container image (#6111) (#6116)
Signed-off-by: Miguel Lopes <miguel.lopes@miguelallopes.dev>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-09 11:38:59 -04:00
Deluan Quintão fe1c87c190 fix(ui): round the album grid hover overlay in the Nautiline theme (#6115)
The theme rounded the cover image directly and set a border radius on
albumContainer, which has no background or clipping, so it rounded
nothing. The hover overlay is a sibling of the image inside the same
link, so it kept square corners that poked out over the rounded cover.

Move the radius to that link and clip it, so both the image and the
overlay follow the same rounded box. This also covers the mobile bar,
which is always visible.

Fixes #6110
2026-09-09 10:52:15 -04:00
Deluan Quintão 043de7a86c docs(jellyfin): correct the rationale for the public image endpoint (#6114)
The comment justified anonymous access with "item ids are unguessable".
That is not true: an artist id is a deterministic, unsalted hash of the
artist name, id.NewHash(id.NewHash(str.Clear(lower(name)))), so it is
computable offline by anyone who knows the name.

The real reason the route is public is that upstream Jellyfin's is too.
ImageController.GetItemImage carries no [Authorize] attribute (verified on
v12.0, master/13.0.0, v10.11.9 and v10.10.7), and an anonymous request
reaches LibraryManager.ItemIsVisible with a null user, which returns true
unconditionally. Clients build cover URLs with no credentials at all, so
requiring auth here would break them.

No behavior change.
2026-09-09 10:42:54 -04:00
Deluan bea9715001 refactor(ui): replace icons in LibraryScanButton with react-icons 2026-09-08 18:51:49 -04:00
Deluan 89026012ab fix(transcoding): make piped FLAC transcodes seekable
The FLAC muxer writes STREAMINFO before it knows the stream length, then
rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout
(-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC
header" and the field stays 0. A decoder needs total_samples to turn a
timestamp into a byte offset, so it reports an unknown duration and refuses to
seek. Online playback hides this because the client re-requests with a new
offset each time, but an offline copy is permanently unseekable, the symptom
reported against Symfonium where seeking a downloaded track jumps back to the
start.

Transcode now wraps its own output and rewrites total_samples as the first
bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that
package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream
only learns a target format and hands back an io.ReadCloser, so compensating
there leaked a transcoder implementation detail one layer up. TranscodeOptions
grows a Duration field alongside the existing Offset, which also puts the
duration-minus-offset arithmetic in the same function that emits -ss.

The wrapper runs on every transcode rather than only FLAC targets: the format
on a transcoding row is a declared target that nothing validates against the
command's actual -f, so a custom command can emit FLAC under any target_format.
The magic-byte check inside the wrapper is the authoritative test and costs a
26-byte peek. The output sample rate is read back out of the header ffmpeg just
wrote rather than taken from the transcode options, so a resampled (-ar) output
still gets the right count. Anything that is not a FLAC stream with an unset
total_samples passes through byte for byte.

Measured on a 177s source: before, total_samples=0 and ffprobe reported
duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio
payload byte-identical. This affects every piped FLAC regardless of the source
format; only FLAC stores an authoritative "unknown", which is why mp3, opus
and aac survive the same pipe.

No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and
decoders binary-search using total_samples alone.
2026-09-07 16:47:42 -04:00
Deluan 404837799b fix(subsonic): don't re-encode a source already in the player's forced format
When a player has a forced transcoding format, ClientInfo.ForceFormat cleared
DirectPlayProfiles unconditionally. A FLAC source on a player configured to
transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth
for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the
resulting FLAC has total_samples=0 and no seek table -- an offline copy of it
can never be seeked. Reported against getTranscodeDecision by the Symfonium
author.

ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding
profiles instead of dropping them: a client declaring a transcoding profile for
a format is proof it can consume that format, so a source already in it is
served as-is. Container and codec come from resolveTargetFormat, so a legacy
"oga" target_format yields an ogg/opus profile, and the profile's
MaxAudioChannels is carried across.

DirectPlayProfile has no bitrate field, so restoring direct play needs a
ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now
seeds that ceiling from the transcoding row's DefaultBitRate when a format was
successfully forced, with the player's own MaxBitRate still taking precedence.
This also closes a gap where the new endpoint ignored DefaultBitRate entirely:
an mp3 320 source on a player forced to mp3@192 was served at 320, while the
legacy /rest/stream path correctly gave 192.

Applied via CapBitrate, which only ever lowers, so a client declaring a
stricter limit keeps it. The legacy path (applyServerOverride) is untouched --
ForceFormat has no other callers.
2026-09-07 14:56:41 -04:00
Deluan 48af781b82 fix(reflex): exclude .worktrees from the reflex configuration regex 2026-09-07 14:43:18 -04:00
79 changed files with 2433 additions and 689 deletions

No files matched your search

-31
View File
@@ -15,37 +15,9 @@ jobs:
env:
COVERAGE_COMMENT: 'true'
steps:
# The pipeline skips its coverage steps when a PR touches no Go code.
- name: Check the run produced a coverage artifact
id: artifact
env:
GH_TOKEN: ${{ github.token }}
run: |
count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.event.workflow_run.id }}/artifacts?name=octocov-pr" \
--jq '.total_count')
echo "count=$count" >> "$GITHUB_OUTPUT"
# A PR that reverts its Go changes produces no artifact, but octocov's
# comment for the earlier head stays. Match the marker it keys off itself.
- name: Delete the stale coverage comment
if: steps.artifact.outputs.count == '0'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
number=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \
--jq ".[] | select(.head.sha == \"$HEAD_SHA\") | .number" | head -n1)
[ -n "$number" ] || exit 0
id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$number/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- octocov -->"))) | .id' | head -n1)
if [ -n "$id" ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$id"
fi
# Only the config, from the base branch: this job holds a write token, so
# it must never check out the fork.
- name: Check out the octocov config
if: steps.artifact.outputs.count != '0'
uses: actions/checkout@v7
with:
sparse-checkout: .octocov.yml
@@ -55,7 +27,6 @@ jobs:
# Into a subdirectory. A pull_request run executes the fork's own copy of
# pipeline.yml, so every file in here is attacker-controlled.
- uses: actions/download-artifact@v8
if: steps.artifact.outputs.count != '0'
with:
name: octocov-pr
path: untrusted
@@ -64,7 +35,6 @@ jobs:
- name: Verify the artifact and take the coverage profile
id: pr
if: steps.artifact.outputs.count != '0'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
@@ -81,7 +51,6 @@ jobs:
echo "number=$number" >> "$GITHUB_OUTPUT"
- uses: k1LoW/octocov-action@v1
if: steps.artifact.outputs.count != '0'
env:
# A workflow_run job looks like a push to the default branch. Point
# octocov back at the pull request and at the run that produced it.
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
#
# Emits per-area change flags to $GITHUB_OUTPUT so the pipeline can skip work a
# pull request cannot affect:
#
# go - Go sources, module files, linter config, embedded resources and
# the go:generate inputs whose generated output CI verifies
# js - anything under ui/
# i18n - translation files and their validation script
# build - anything that ends up in a binary, image or package (i.e. every
# change except the doc-only paths in $DOC_ONLY_RE)
#
# pipeline.yml and this script are inputs to every suite they gate: a wrong
# gating expression skips a suite green, in every run, with no other signal.
#
# Only pull requests are narrowed. Master pushes, tags and manual runs always get
# every flag, so a release can never be built from a partially validated tree.
#
# Flags gate STEPS, not jobs: a job-level skip propagates through the needs
# chain (actions/runner#491) and would take the release jobs down with it.
#
# Compares HEAD against $BASE_REF (default master). Requires full history
# (fetch-depth: 0 in CI).
set -uo pipefail
export LC_ALL=C
GO_RE='(\.go$|(^|/)go\.(mod|sum)$|^Makefile$|^\.golangci\.yml$|^resources/|^db/migrations/|^tests/|^plugins/manifest-schema\.json$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
JS_RE='(^ui/|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
I18N_RE='(^resources/i18n/|^ui/src/i18n/en\.json$|^\.github/workflows/validate-translations\.sh$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
DOC_ONLY_RE='(\.md$|^LICENSE$|^\.git-blame-ignore-revs$|^\.gitignore$|^\.devcontainer/)'
emit() { printf '%s=%s\n' "$1" "$2" | tee -a "${GITHUB_OUTPUT:-/dev/null}"; }
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
echo "Not a pull request — running everything."
for area in go js i18n build; do emit "$area" true; done
exit 0
fi
BASE_REF="${BASE_REF:-master}"
git fetch --no-tags --quiet origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true
# Guard the diff, not the fetch: checkout already created the ref, so a failed
# refresh is harmless, but an unresolvable ref would emit every flag as false.
if ! git rev-parse --verify --quiet "origin/${BASE_REF}" >/dev/null; then
printf '::error::Cannot resolve origin/%s. In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
exit 1
fi
# --no-renames: rename detection reports only the destination, so moving a file
# out of a gated area would drop the source path from every filter.
files="$(git diff --no-renames --name-only "origin/${BASE_REF}...HEAD")"
echo "Changed files:"
printf '%s\n' "$files" | sed 's/^/ /'
echo
flag() { # $1=name $2=regex
if grep -qE "$2" <<< "$files"; then emit "$1" true; else emit "$1" false; fi
}
flag go "$GO_RE"
flag js "$JS_RE"
flag i18n "$I18N_RE"
if [ -n "$(grep -vE "$DOC_ONLY_RE" <<< "$files")" ]; then
emit build true
else
emit build false
fi
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# Tests detect-changes.sh against a throwaway repo: builds a synthetic PR for
# each change shape and asserts the four emitted flags.
#
# ./.github/workflows/detect-changes_test.sh # tests the sibling script
# ./.github/workflows/detect-changes_test.sh <path> # tests another copy
#
# To confirm a case still bites, edit a pattern out of detect-changes.sh and
# re-run: exactly the case that covers it should fail.
set -uo pipefail
SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/detect-changes.sh}"
T=$(mktemp -d); O=$(mktemp -d)
cd "$T"
git init -q -b master .
git config user.email t@t; git config user.name t
mkdir -p ui/src/i18n resources/i18n .github/workflows db/migrations
echo x > README.md; echo x > main.go; echo x > ui/src/a.js
echo x > resources/i18n/pt.json; echo x > ui/src/i18n/en.json; echo x > go.mod
git add -A; git commit -qm base
git clone -q --bare . "$O/origin.git"
git remote add origin "$O/origin.git"
git fetch -q origin
fails=0
run() { # $1=label $2=expected "go js i18n build" ; rest=files
label="$1"; want="$2"; shift 2
git checkout -q -B test master
for f in "$@"; do mkdir -p "$(dirname "$f")"; echo change >> "$f"; done
git add -A >/dev/null; git commit -qm "$label"
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
if [ "$got" = "$want" ]; then printf 'ok %-32s %s\n' "$label" "$got"
else printf 'FAIL %-32s got[%s] want[%s]\n' "$label" "$got" "$want"; fails=$((fails+1)); fi
}
# go js i18n build
run "docs only" "false false false false" README.md
run "gitignore only" "false false false false" .gitignore
run "go only" "true false false true" core/thing.go
run "ui only" "false true false true" ui/src/b.js
run "i18n resources" "true false true true" resources/i18n/fr.json
run "ui en.json" "false true true true" ui/src/i18n/en.json
run "ui other i18n" "false true false true" ui/src/i18n/provider.js
run "db migration sql" "true false false true" db/migrations/20260101000000_x.sql
run "tests fixture" "true false false true" tests/fixtures/playlist.m3u
run "tests toml" "true false false true" tests/navidrome-test.toml
run "conf testdata" "false false false true" conf/testdata/cfg.toml
run "manifest schema" "true false false true" plugins/manifest-schema.json
run "other plugin json" "false false false true" plugins/testdata/fake/manifest-schema.json
run "nested go.mod" "true false false true" plugins/testdata/x/go.mod
run "Dockerfile only" "false false false true" Dockerfile
run "pipeline.yml" "true true true true" .github/workflows/pipeline.yml
run "detect-changes.sh" "true true true true" .github/workflows/detect-changes.sh
run "other workflow" "false false false true" .github/workflows/stale.yml
run "validate-trans.sh" "false false true true" .github/workflows/validate-translations.sh
echo "--- large diff must not lose flags to SIGPIPE ---"
git checkout -q -B test master
mkdir -p big/pkg
python3 -c "
import os
os.makedirs('big/pkg', exist_ok=True)
open('big/pkg/aaa_first.go','w').write('x')
for i in range(2500): open('big/pkg/filler_%04d.txt' % i,'w').write('x')
"
git add -A >/dev/null; git commit -qm big
nfiles=$(git diff --no-renames --name-only master...HEAD | wc -l | tr -d ' ')
for i in 1 2 3 4 5; do
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
if [ "$got" = "true false false true" ]; then printf 'ok %-32s %s (%s files)\n' "large diff run $i" "$got" "$nfiles"
else printf 'FAIL %-32s got[%s] want[true false false true] (%s files)\n' "large diff run $i" "$got" "$nfiles"; fails=$((fails+1)); fi
done
echo "--- renames must count the source path ---"
rn() { # $1=label $2=expected $3=from $4=to
git checkout -q -B test master
mkdir -p "$(dirname "$4")"; git mv "$3" "$4"
git add -A >/dev/null; git commit -qm "$1"
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
if [ "$got" = "$2" ]; then printf 'ok %-32s %s\n' "$1" "$got"
else printf 'FAIL %-32s got[%s] want[%s]\n' "$1" "$got" "$2"; fails=$((fails+1)); fi
}
# go js i18n build
rn "ui .js -> docs .md" "false true false true" ui/src/a.js docs/a.js.md
rn "go -> docs .md" "true false false true" main.go docs/main.go.md
echo "--- non-PR events ---"
git checkout -q master
for ev in push workflow_dispatch; do
got=$(GITHUB_EVENT_NAME=$ev bash "$SCRIPT" 2>&1 | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
if [ "$got" = "true true true true" ]; then printf 'ok %-32s %s\n' "$ev" "$got"
else printf 'FAIL %-32s got[%s]\n' "$ev" "$got"; fails=$((fails+1)); fi
done
echo "--- unresolvable base ref must fail closed ---"
git checkout -q -B test master; echo x >> main.go; git add -A >/dev/null; git commit -qm x
out=$(GITHUB_EVENT_NAME=pull_request BASE_REF=does-not-exist bash "$SCRIPT" 2>&1); rc=$?
if [ "$rc" != "0" ] && ! grep -qE '^(go|js|i18n|build)=' <<<"$out"; then
printf 'ok %-32s exit=%s, no flags emitted\n' "bad base ref" "$rc"
else
printf 'FAIL %-32s exit=%s out[%s]\n' "bad base ref" "$rc" "$out"; fails=$((fails+1))
fi
echo
echo "failures: $fails"
cd /; rm -rf "$T" "$O"
exit "$fails"
+5 -14
View File
@@ -35,27 +35,18 @@ jobs:
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
const header = `Download the artifacts for this pull request:`;
const comments = await github.paginate(github.rest.issues.listComments, {repo, owner, issue_number});
// Match on the body too: octocov also comments as github-actions[bot].
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
// A PR that changes nothing reaching a binary builds nothing. Delete
// rather than reword: the matcher above keys off the header.
if (!downloadable.length) {
if (existing_comment) {
core.info(`Deleting stale comment ${existing_comment.id}`);
await github.rest.issues.deleteComment({repo, owner, comment_id: existing_comment.id});
}
return core.info(`No artifacts found`);
return core.error(`No artifacts found`);
}
const header = `Download the artifacts for this pull request:`;
let body = `${header}\n`;
for (const art of downloadable) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
// Match on the body too: octocov also comments as github-actions[bot].
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
+20 -111
View File
@@ -8,7 +8,6 @@ on:
pull_request:
branches:
- master
workflow_dispatch:
concurrency:
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
@@ -59,36 +58,13 @@ jobs:
echo "GIT_TAG=$GIT_TAG"
echo "GIT_SHA=$GIT_SHA"
# Outputs gate steps, never jobs: a job-level skip propagates through the needs
# chain (actions/runner#491) and would skip all release jobs on tag pushes.
changes:
name: Detect changed areas
runs-on: ubuntu-latest
outputs:
go: ${{ steps.detect.outputs.go }}
js: ${{ steps.detect.outputs.js }}
i18n: ${{ steps.detect.outputs.i18n }}
build: ${{ steps.detect.outputs.build }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect changed areas
id: detect
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: ./.github/workflows/detect-changes.sh
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
needs: [changes]
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.go == 'true'
- uses: actions/setup-go@v6
if: needs.changes.outputs.go == 'true'
with:
go-version-file: go.mod
@@ -96,11 +72,9 @@ jobs:
# cannot turn red in CI just because a new golangci-lint was released.
- name: Resolve golangci-lint version
id: golangci-version
if: needs.changes.outputs.go == 'true'
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
if: needs.changes.outputs.go == 'true'
uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.golangci-version.outputs.version }}
@@ -108,12 +82,9 @@ jobs:
args: --timeout 2m
- name: Run go goimports
if: needs.changes.outputs.go == 'true'
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
- if: needs.changes.outputs.go == 'true'
run: go mod tidy
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
if: needs.changes.outputs.go == 'true'
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
@@ -122,10 +93,8 @@ jobs:
fi
- name: Run go generate
if: needs.changes.outputs.go == 'true'
run: go generate ./...
- name: Verify no changes from go generate
if: needs.changes.outputs.go == 'true'
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
@@ -157,29 +126,23 @@ jobs:
go:
name: Test Go code
runs-on: ubuntu-latest
needs: [changes]
steps:
- name: Check out code into the Go module directory
if: needs.changes.outputs.go == 'true'
uses: actions/checkout@v7
- uses: actions/setup-go@v6
if: needs.changes.outputs.go == 'true'
with:
go-version-file: go.mod
- name: Download dependencies
if: needs.changes.outputs.go == 'true'
run: go mod download
# Name must stay unique across the workflow: octocov matches step names
# by name across every job, and waits for each match to finish.
- name: Test with coverage
if: needs.changes.outputs.go == 'true'
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
- name: Test ndpgen
if: needs.changes.outputs.go == 'true'
run: |
cd plugins/cmd/ndpgen
go test -shuffle=on -v
@@ -187,7 +150,6 @@ jobs:
./ndpgen --help
- name: Upload coverage profile
if: needs.changes.outputs.go == 'true'
uses: actions/upload-artifact@v7
with:
name: octocov-go
@@ -197,22 +159,18 @@ jobs:
go-plugins:
name: Test Go plugins
runs-on: ubuntu-latest
needs: [changes]
steps:
- name: Check out code into the Go module directory
if: needs.changes.outputs.go == 'true'
uses: actions/checkout@v7
- uses: actions/setup-go@v6
id: setup-go
if: needs.changes.outputs.go == 'true'
with:
go-version-file: go.mod
# Without this, the suite recompiles every test plugin WASM module,
# which dominates its runtime under -race.
- name: Cache the WASM compilation cache
if: needs.changes.outputs.go == 'true'
uses: actions/cache@v6
with:
path: plugins/testdata/.wazero-cache
@@ -220,11 +178,9 @@ jobs:
restore-keys: wazero-${{ runner.os }}-
- name: Test plugins
if: needs.changes.outputs.go == 'true'
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
- name: Upload coverage profile
if: needs.changes.outputs.go == 'true'
uses: actions/upload-artifact@v7
with:
name: octocov-plugins
@@ -234,7 +190,7 @@ jobs:
coverage:
name: Report coverage
runs-on: ubuntu-latest
needs: [changes, go, go-plugins]
needs: [go, go-plugins]
permissions:
contents: read
actions: write
@@ -242,31 +198,27 @@ jobs:
COVERAGE_COMMENT: 'false'
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.go == 'true'
- uses: actions/download-artifact@v8
if: needs.changes.outputs.go == 'true'
with:
pattern: octocov-*
# Merge here rather than letting octocov do it: octocov reports statement
# coverage for a single profile, but switches to line counting for several.
- name: Merge coverage profiles
if: needs.changes.outputs.go == 'true'
run: |
echo "mode: atomic" > coverage.out
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
octocov-*/coverage.out | sort >> coverage.out
- uses: k1LoW/octocov-action@v1
if: needs.changes.outputs.go == 'true'
- name: Save the PR number for the comment workflow
if: github.event_name == 'pull_request' && needs.changes.outputs.go == 'true'
if: github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > pr_number
- name: Upload the merged profile for the comment workflow
if: github.event_name == 'pull_request' && needs.changes.outputs.go == 'true'
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: octocov-pr
@@ -278,33 +230,27 @@ jobs:
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
needs: [changes]
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.go == 'true'
- uses: actions/setup-go@v6
if: needs.changes.outputs.go == 'true'
with:
go-version-file: go.mod
- uses: msys2/setup-msys2@v2
if: needs.changes.outputs.go == 'true'
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw64 to PATH
if: needs.changes.outputs.go == 'true'
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Cache ffmpeg
if: needs.changes.outputs.go == 'true'
id: ffmpeg-cache
uses: actions/cache@v6
with:
@@ -312,7 +258,7 @@ jobs:
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
- name: Download ffmpeg
if: needs.changes.outputs.go == 'true' && steps.ffmpeg-cache.outputs.cache-hit != 'true'
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
@@ -324,12 +270,10 @@ jobs:
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
- name: Add ffmpeg to PATH
if: needs.changes.outputs.go == 'true'
shell: bash
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
- name: Verify toolchain
if: needs.changes.outputs.go == 'true'
shell: pwsh
run: |
go version
@@ -339,19 +283,16 @@ jobs:
ffprobe -version
- name: Download dependencies
if: needs.changes.outputs.go == 'true'
shell: bash
run: go mod download
- name: Test
if: needs.changes.outputs.go == 'true'
shell: bash
env:
CGO_ENABLED: "1"
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
if: needs.changes.outputs.go == 'true'
shell: bash
run: |
cd plugins/cmd/ndpgen
@@ -362,39 +303,32 @@ jobs:
js:
name: Test JS code
runs-on: ubuntu-latest
needs: [changes]
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.js == 'true'
- uses: actions/setup-node@v6
if: needs.changes.outputs.js == 'true'
with:
node-version: 24
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: npm install dependencies
if: needs.changes.outputs.js == 'true'
run: |
cd ui
npm ci
- name: npm lint
if: needs.changes.outputs.js == 'true'
run: |
cd ui
npm run check-formatting && npm run lint
- name: npm test
if: needs.changes.outputs.js == 'true'
run: |
cd ui
npm test
- name: npm build
if: needs.changes.outputs.js == 'true'
run: |
cd ui
npm run build
@@ -402,12 +336,9 @@ jobs:
i18n-lint:
name: Lint i18n files
runs-on: ubuntu-latest
needs: [changes]
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.i18n == 'true'
- if: needs.changes.outputs.i18n == 'true'
run: |
- run: |
set -e
for file in resources/i18n/*.json; do
echo "Validating $file"
@@ -419,7 +350,6 @@ jobs:
fi
done
- run: ./.github/workflows/validate-translations.sh -v
if: needs.changes.outputs.i18n == 'true'
check-push-enabled:
@@ -434,7 +364,7 @@ jobs:
build:
name: Build
needs: [changes, js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
@@ -443,23 +373,19 @@ jobs:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
SHOULD_BUILD: ${{ needs.changes.outputs.build }}
DOCKER_BUILD_SUMMARY: false
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
steps:
- name: Sanitize platform name
if: env.SHOULD_BUILD == 'true'
id: set-platform
run: |
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v7
if: env.SHOULD_BUILD == 'true'
- name: Prepare Docker Buildx
if: env.SHOULD_BUILD == 'true'
uses: ./.github/actions/prepare-docker
id: docker
with:
@@ -469,7 +395,6 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
if: env.SHOULD_BUILD == 'true'
uses: docker/build-push-action@v7
with:
context: .
@@ -483,14 +408,14 @@ jobs:
GIT_TAG=${{ env.GIT_TAG }}
- name: Set up QEMU for smoke test
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true'
if: env.IS_LINUX == 'true'
uses: docker/setup-qemu-action@v4
# The binary is static, so binfmt+qemu runs it directly on the runner.
# Catches startup crashes in cross-compiled binaries before they ship,
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
- name: Smoke-test binary
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true'
if: env.IS_LINUX == 'true'
run: |
BIN=./output/${{ env.PLATFORM }}/navidrome
chmod +x "$BIN"
@@ -498,7 +423,6 @@ jobs:
echo "OK: ${{ matrix.platform }} binary starts"
- name: Upload Binaries
if: env.SHOULD_BUILD == 'true'
uses: actions/upload-artifact@v7
with:
name: navidrome-${{ env.PLATFORM }}
@@ -507,7 +431,7 @@ jobs:
- name: Build and push image by digest
id: push-image
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v7
with:
context: .
@@ -522,7 +446,7 @@ jobs:
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push-image.outputs.digest }}"
@@ -530,7 +454,7 @@ jobs:
- name: Upload digest
uses: actions/upload-artifact@v7
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
path: /tmp/digests/*
@@ -543,8 +467,8 @@ jobs:
contents: read
packages: write
runs-on: ubuntu-latest
needs: [changes, build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && needs.changes.outputs.build == 'true'
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
@@ -578,8 +502,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
needs: [changes, build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' && needs.changes.outputs.build == 'true'
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v7
@@ -631,26 +555,22 @@ jobs:
msi:
name: Build Windows installers
needs: [changes, build, git-version]
needs: [build, git-version]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.build == 'true'
- uses: actions/download-artifact@v8
if: needs.changes.outputs.build == 'true'
with:
path: ./binaries
pattern: navidrome-windows*
merge-multiple: true
- name: Install Wix
if: needs.changes.outputs.build == 'true'
run: sudo apt-get install -y wixl jq
- name: Build MSI
if: needs.changes.outputs.build == 'true'
env:
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
run: |
@@ -660,7 +580,6 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
if: needs.changes.outputs.build == 'true'
uses: actions/upload-artifact@v7
with:
name: navidrome-windows-installers
@@ -669,33 +588,29 @@ jobs:
release:
name: Package/Release
needs: [changes, build, msi]
needs: [build, msi]
runs-on: ubuntu-latest
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v7
if: needs.changes.outputs.build == 'true'
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v8
if: needs.changes.outputs.build == 'true'
with:
path: ./binaries
pattern: navidrome-*
merge-multiple: true
- run: ls -lR ./binaries
if: needs.changes.outputs.build == 'true'
- name: Set RELEASE_FLAGS for snapshot releases
if: needs.changes.outputs.build == 'true' && env.IS_RELEASE == 'false'
if: env.IS_RELEASE == 'false'
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
if: needs.changes.outputs.build == 'true'
uses: goreleaser/goreleaser-action@v7
with:
version: '2.16.0'
@@ -704,20 +619,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove build artifacts
if: needs.changes.outputs.build == 'true'
run: |
ls -l ./dist
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
if: needs.changes.outputs.build == 'true'
uses: actions/upload-artifact@v7
with:
name: packages
path: dist/navidrome_0*
- id: set-package-list
if: needs.changes.outputs.build == 'true'
name: Export list of generated packages
run: |
cd dist
@@ -729,10 +641,7 @@ jobs:
upload-packages:
name: Upload Linux PKG
runs-on: ubuntu-latest
needs: [changes, release]
# Job-level skip is safe here: nothing needs this job, and fromJson would
# error on the empty package_list a skipped release leaves behind.
if: needs.changes.outputs.build == 'true'
needs: [release]
strategy:
matrix:
item: ${{ fromJson(needs.release.outputs.package_list) }}
+1 -1
View File
@@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
# otherwise the deleted bytes still ship in the image.
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
+1 -1
View File
@@ -132,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
sessionKey, err := s.client.getSession(ctx, token)
if err != nil {
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
"requestId", middleware.GetReqID(ctx), err)
return err
}
+24 -64
View File
@@ -2,9 +2,7 @@ package cmd
import (
"context"
"fmt"
"os"
"strings"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@@ -31,7 +29,7 @@ func init() {
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
@@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
requireExistingDB()
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled")
return
}
requireExistingDB()
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
requireExistingDB()
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
// A relative --backup-file is resolved against Backup.Path, the same folder
// `backup create` writes to. Without this, the value was treated as relative
// to the working directory, where the file does not exist.
if !filepath.IsAbs(restorePath) {
backupPath, err := conf.Server.Backup.Path.Path()
if err != nil {
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
return
}
restorePath = filepath.Join(backupPath, restorePath)
}
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
log.Warn("Restore cancelled")
return
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error restoring database", "backupFile", restorePath, err)
}
elapsed := time.Since(start)
+99
View File
@@ -0,0 +1,99 @@
package cmd
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"github.com/navidrome/navidrome/db"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(doctorCmd)
}
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Check your Navidrome installation for problems",
Long: "Run read-only health checks and report what was found. Checks the database for " +
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
"can fix what it finds. This command never alters your data",
Run: func(cmd *cobra.Command, _ []string) {
runDoctor(cmd.Context())
},
}
func runDoctor(ctx context.Context) {
requireExistingDB()
healthy := doctor(ctx, db.Db(), os.Stdout)
db.Close(ctx)
if !healthy {
os.Exit(1)
}
}
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
func printFindings(out io.Writer, check, noun string, items []string) {
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
for _, item := range items {
fmt.Fprintln(out, " "+item)
}
}
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
healthy := true
fmt.Fprintln(out, "Checking database integrity...")
issues, truncated, err := db.IntegrityCheck(ctx, database)
switch {
case err != nil:
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
fmt.Fprintln(out, recoveryAdvice)
return false
case len(issues) == 0:
fmt.Fprintln(out, "Integrity check passed.")
default:
healthy = false
printFindings(out, "Integrity check", "issue(s)", issues)
switch {
case truncated:
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
fmt.Fprintln(out, recoveryAdvice)
case db.IsFTSCorruptionOnly(issues):
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
default:
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
fmt.Fprintln(out, recoveryAdvice)
}
}
fmt.Fprintln(out, "Checking foreign keys...")
violations, err := db.ForeignKeyCheck(ctx, database)
switch {
case err != nil:
healthy = false
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
case len(violations) == 0:
fmt.Fprintln(out, "Foreign key check passed.")
default:
healthy = false
lines := make([]string, 0, len(violations))
for _, v := range violations {
lines = append(lines,
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
}
printFindings(out, "Foreign key check", "violation(s)", lines)
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
"in library data; the rest have to be removed by hand.")
}
if healthy {
fmt.Fprintln(out, "Database is healthy.")
}
return healthy
}
+124
View File
@@ -0,0 +1,124 @@
package cmd
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/db"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("doctor", func() {
var (
ctx context.Context
dbPath string
database *sql.DB
out *strings.Builder
reopen func()
)
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
BeforeEach(func() {
ctx = context.Background()
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
reopen = func() {
var err error
database, err = sql.Open(db.Dialect, dbPath)
Expect(err).ToNot(HaveOccurred())
database.SetMaxOpenConns(1)
}
reopen()
DeferCleanup(func() { _ = database.Close() })
for _, stmt := range []string{
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
`create table library(id integer primary key)`,
`create table media_file(id integer primary key, library_id integer references library(id))`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
out = &strings.Builder{}
})
It("reports a healthy database", func() {
Expect(doctor(ctx, database, out)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("Database is healthy."))
})
It("points to 'search rebuild' when corruption is limited to the search index", func() {
_, err := database.ExecContext(ctx,
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
})
It("points to a backup restore when corruption is not limited to the search index", func() {
_, err := database.ExecContext(ctx,
`insert into library(id)
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
select x from s`)
Expect(err).ToNot(HaveOccurred())
var rootPage, pageSize int64
Expect(database.QueryRowContext(ctx,
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
Expect(database.Close()).To(Succeed())
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
Expect(err).ToNot(HaveOccurred())
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
Expect(err).ToNot(HaveOccurred())
Expect(f.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("backup restore"))
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
})
It("reports foreign key violations", func() {
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
Expect(err).ToNot(HaveOccurred())
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
Expect(out.String()).To(ContainSubstring("media_file"))
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
// GC never touches player, share or playqueue, so don't promise a full cleanup.
Expect(out.String()).To(ContainSubstring("removed by hand"))
})
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
It("does not blame the search index when the issue list is truncated", func() {
for _, stmt := range []string{
`create table t(a, b)`,
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
insert into t select x, x + 10000 from s`,
`create index media_file_fts_probe on t(a)`,
`pragma writable_schema=on`,
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
where name = 'media_file_fts_probe'`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
Expect(database.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
Expect(out.String()).To(ContainSubstring("backup restore"))
})
})
+55
View File
@@ -0,0 +1,55 @@
package cmd
import (
"context"
"fmt"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var searchRebuildForce bool
func init() {
rootCmd.AddCommand(searchRoot)
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
searchRoot.AddCommand(searchRebuildCmd)
}
var (
searchRoot = &cobra.Command{
Use: "search",
Short: "Search index maintenance",
}
searchRebuildCmd = &cobra.Command{
Use: "rebuild",
Short: "Rebuild the full-text search index",
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
"library. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runSearchRebuild(cmd.Context())
},
}
)
func runSearchRebuild(ctx context.Context) {
requireExistingDB()
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
log.Warn("Rebuild cancelled")
return
}
fmt.Println("Rebuilding the search index...")
err := db.RebuildFTS(ctx, db.Db())
db.Close(ctx)
if err != nil {
log.Fatal("Error rebuilding the search index", err)
}
fmt.Println("Search index rebuilt successfully.")
}
+20
View File
@@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -15,6 +18,23 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// requireExistingDB aborts the command when the database file (DbPath minus DSN
// params) does not exist.
func requireExistingDB() {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
}
}
func confirmYES(warning string) bool {
fmt.Println(warning)
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
return input == "YES" && err == nil
}
// newTabWriter keeps every CLI table on the same column settings.
func newTabWriter(out io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+5 -5
View File
@@ -70,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
insights := metrics.GetInstance(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
@@ -103,7 +103,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
@@ -189,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@@ -200,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -249,7 +249,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()
+1 -1
View File
@@ -42,7 +42,7 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
scanner.GetInstance,
scanner.GetWatcher,
metrics.GetPrometheusInstance,
db.Db,
+1 -1
View File
@@ -407,7 +407,7 @@ func Load(noConfigDump bool) {
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
}
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
}
+16
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@@ -329,6 +330,21 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("creates the log file readable only by the owner", func() {
if runtime.GOOS == "windows" {
Skip("file modes are not enforced on Windows")
}
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", logFile)
DeferCleanup(log.SetOutput, os.Stderr)
conf.Load(true)
info, err := os.Stat(logFile)
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")
+1 -1
View File
@@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
original, format, err := decodeCapped(data)
if err != nil {
return nil, 0, err
}
+13
View File
@@ -0,0 +1,13 @@
package artwork
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizeStaticImage", func() {
It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() {
_, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false)
Expect(err).To(MatchError(ContainSubstring("exceed pixel cap")))
})
})
+11 -6
View File
@@ -27,11 +27,12 @@ type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
out, err := e.start(ctx, args)
if err != nil {
return nil, err
}
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
+35
View File
@@ -3,6 +3,7 @@ package ffmpeg
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
})
Expect(err).To(MatchError(context.Canceled))
})
It("fills in total_samples on a piped FLAC transcode", func() {
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 1, // the fixture is exactly 1s at 44100Hz
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(string(out[:4])).To(Equal("fLaC"))
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("patches the duration net of the requested offset", func() {
// The command has no %t, so ffmpeg still emits the whole fixture.
// What is under test is the header arithmetic, not the audio.
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "tests/fixtures/test.flac",
Duration: 3,
Offset: 1,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
})
})
Context("stderr capture", func() {
+66
View File
@@ -0,0 +1,66 @@
package ffmpeg
import (
"bytes"
"encoding/binary"
"errors"
"io"
"math"
)
const (
flacPrefixLen = 26 // through the last total_samples byte
flacMaxTotalSamples = 1<<36 - 1
)
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
if duration <= 0 {
return r
}
return &flacPatcher{ReadCloser: r, duration: duration}
}
type flacPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (f *flacPatcher) Read(p []byte) (int, error) {
if f.stream == nil {
prefix := make([]byte, flacPrefixLen)
n, err := io.ReadFull(f.ReadCloser, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return 0, err
}
prefix = prefix[:n]
if err == nil {
setFLACTotalSamples(prefix, f.duration)
}
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
}
return f.stream.Read(p)
}
// setFLACTotalSamples takes the rate from the header rather than the transcode
// options, so a resampled (-ar) output still gets the right count.
func setFLACTotalSamples(prefix []byte, duration float32) {
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
return
}
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
info := binary.BigEndian.Uint64(prefix[18:])
rate := info >> 44
if rate == 0 || info&flacMaxTotalSamples != 0 {
return
}
total := math.Round(float64(duration) * float64(rate))
if total > flacMaxTotalSamples {
return
}
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
}
+142
View File
@@ -0,0 +1,142 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Decoded independently so the specs do not mirror the production bit-twiddling.
func readSampleRate(b []byte) int {
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
}
func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
}
var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits.
pipedFLAC := func() []byte {
b := bytes.Clone(fileFLAC)
b[21] &= 0xF0
clear(b[22:26])
return b
}
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() {
var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
Expect(err).ToNot(HaveOccurred())
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
})
It("fills in total_samples from the duration", func() {
out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("takes the sample rate from the header, not from the source file", func() {
in := pipedFLAC()
// Rewrite the header's rate to 48000, as -ar would.
in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F
out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
})
It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
})
It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC()
Expect(readAll(in, 2e6)).To(Equal(in))
})
It("leaves everything after the header untouched", func() {
in := pipedFLAC()
out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18]))
})
It("leaves an already-populated total_samples alone", func() {
out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC))
})
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
})
It("passes through when the header declares no sample rate", func() {
in := pipedFLAC()
in[18], in[19] = 0, 0
in[20] &= 0x0F
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }
+31 -14
View File
@@ -2,6 +2,8 @@ package core
import (
"context"
"fmt"
"slices"
"strings"
"time"
@@ -98,27 +100,19 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
s.ResourceType, err = r.resourceType(s.ResourceIDs)
if err != nil {
return "", err
}
switch v.(type) {
case *model.Artist:
s.ResourceType = "artist"
switch s.ResourceType {
case "artist":
s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs)
case *model.Album:
s.ResourceType = "album"
case "album":
s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs)
case *model.Playlist:
s.ResourceType = "playlist"
case "playlist":
s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs)
case *model.MediaFile:
s.ResourceType = "media_file"
case "media_file":
s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs)
default:
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
@@ -126,6 +120,29 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
return r.Persistable.Save(s)
}
var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork}
// resourceType resolves every ID as the current user, so an entity they cannot see cannot
// ride along behind a valid first one, and requires all IDs to be of the same kind.
func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) {
resourceType := ""
for _, id := range strings.Split(resourceIDs, ",") {
kind, err := model.GetEntityKindByID(r.ctx, r.ds, id)
if err != nil {
return "", err
}
if !slices.Contains(shareableKinds, kind) {
log.Error(r.ctx, "Invalid Resource ID", "id", id)
return "", model.ErrNotFound
}
if resourceType != "" && kind.String() != resourceType {
return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind)
}
resourceType = kind.String()
}
return resourceType, nil
}
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
cols := []string{"description", "downloadable"}
+13
View File
@@ -70,6 +70,19 @@ var _ = Describe("Share", func() {
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
It("fails when any of the resource IDs does not exist", func() {
entity := &model.Share{Description: "test", ResourceIDs: "123,missing"}
_, err := repo.Save(entity)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("fails when the resource IDs are of mixed types", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "123,456"}
_, err := repo.Save(entity)
Expect(err).To(HaveOccurred())
})
})
Describe("Update", func() {
+8 -15
View File
@@ -3,6 +3,7 @@ package local
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -18,22 +19,18 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
return nil, errors.New("watcher already started")
}
input := make(chan notify.EventInfo, 500)
output := make(chan string, 500)
libPath := filepath.Join(s.u.Path, "...")
log.Debug(ctx, "Starting watcher", "lib", libPath)
if err := notify.Watch(libPath, input, WatchEvents); err != nil {
s.watching.Store(false)
return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err)
}
started := make(chan struct{})
output := make(chan string, 500)
go func() {
defer close(input)
defer close(output)
libPath := filepath.Join(s.u.Path, "...")
log.Debug(ctx, "Starting watcher", "lib", libPath)
err := notify.Watch(libPath, input, WatchEvents)
if err != nil {
log.Error("Error starting watcher", "lib", libPath, err)
return
}
defer notify.Stop(input)
close(started) // signals the main goroutine we have started
for {
select {
@@ -49,9 +46,5 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
}
}
}()
select {
case <-started:
case <-ctx.Done():
}
return output, nil
}
+15
View File
@@ -137,3 +137,18 @@ type noopExtractor struct{}
func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil }
func (s noopExtractor) Version() string { return "0" }
var _ = Describe("Watcher.Start", func() {
It("returns an error instead of hanging when the path cannot be watched", func() {
local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} })
conf.Server.Scanner.Extractor = "noop"
ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist"))
Expect(err).ToNot(HaveOccurred())
lsw := ls.(storage.Watcher)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = lsw.Start(ctx)
Expect(err).To(HaveOccurred())
})
})
+76
View File
@@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() {
})
})
Context("Player-forced format", func() {
symfonium := func() *ClientInfo {
return &ClientInfo{
Name: "Symfonium",
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
}
It("direct plays a flac source forced to flac", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("still transcodes a 24-bit flac when the client caps bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := symfonium()
ci.CodecProfiles = []CodecProfile{{
Type: CodecProfileTypeAudio, Name: "flac",
Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
}}
Expect(ci.ForceFormat("flac")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
})
It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetBitrate).To(Equal(192))
})
It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
ci.CapBitrate(192)
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeTrue())
})
It("transcodes a flac source forced to mp3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := symfonium()
Expect(ci.ForceFormat("mp3")).To(BeTrue())
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanDirectPlay).To(BeFalse())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
})
})
})
Describe("ensureProbed", func() {
+1
View File
@@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
Duration: job.mf.Duration,
})
if err != nil {
release()
+18 -8
View File
@@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
return changed
}
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
// direct play, but only if the client already declares a profile for that
// format. All matching profiles are kept so negotiation can still pick among
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
// unsupported.
// ForceFormat narrows the client to transcoding to targetFormat, but only if the
// client already declares a profile for it. All matching profiles are kept so
// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt
// from those profiles rather than dropped, since declaring a transcoding profile
// for a format is proof the client can play it. Returns false when unsupported.
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
if targetFormat == "" {
return false
}
var matched []Profile
var directPlay []DirectPlayProfile
for i := range ci.TranscodingProfiles {
p := &ci.TranscodingProfiles[i]
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
// target_format) still matches a resolved "opus" profile.
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
container, format := resolveTargetFormat(p)
if !matchesContainer(format, []string{targetFormat}) {
continue
}
matched = append(matched, *p)
directPlay = append(directPlay, DirectPlayProfile{
Containers: []string{container},
AudioCodecs: []string{format},
Protocols: []string{ProtocolHTTP},
MaxAudioChannels: p.MaxAudioChannels,
})
}
if len(matched) == 0 {
return false
}
ci.TranscodingProfiles = matched
ci.DirectPlayProfiles = nil
ci.DirectPlayProfiles = directPlay
return true
}
+30 -2
View File
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
})
Describe("ForceFormat", func() {
It("restricts to the forced format and clears direct play when supported", func() {
It("restricts direct play to the forced format when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
@@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() {
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP},
}))
})
It("keeps direct play for a source already in the forced format", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
ok := ci.ForceFormat("flac")
Expect(ok).To(BeTrue())
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP},
}))
})
It("carries the channel limit of the forced profile into direct play", func() {
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
},
}
Expect(ci.ForceFormat("flac")).To(BeTrue())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2))
})
It("matches a container-only forced format (mp3)", func() {
+14 -1
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/mattn/go-sqlite3"
@@ -18,7 +19,7 @@ import (
const (
backupPrefix = "navidrome_backup"
backupRegexString = backupPrefix + "_(.+)\\.db"
backupRegexString = "^" + backupPrefix + "_(.+)\\.db$"
)
var backupRegex = regexp.MustCompile(backupRegexString)
@@ -40,6 +41,18 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
}
defer existingConn.Close()
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
// path would create an empty database and "restore" it over the live one.
if !isBackup {
// The driver splits the DSN at '?', so such a path would open a different file.
if strings.ContainsRune(path, '?') {
return fmt.Errorf("backup path cannot contain '?': %s", path)
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup file not available: %w", err)
}
}
backupDb, err := sql.Open(Driver, path)
if err != nil {
return fmt.Errorf("opening backup database in '%s': %w", path, err)
+89
View File
@@ -5,12 +5,14 @@ import (
"database/sql"
"math/rand"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/singleton"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -103,6 +105,19 @@ var _ = Describe("database backups", func() {
Entry("delete all files", 0, 0),
Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)),
Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically)))
It("ignores SQLite sidecar files when counting backups", func() {
for _, suffix := range []string{"-shm", "-wal"} {
file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix)
Expect(err).ToNot(HaveOccurred())
_ = file.Close()
}
conf.Server.Backup.Count = len(timesDecreasingChronologically)
pruneCount, err := Prune(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(pruneCount).To(BeZero())
})
})
Describe("backup and restore", Ordered, func() {
@@ -148,4 +163,78 @@ var _ = Describe("database backups", func() {
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
})
})
Describe("backup and restore with a file-based database", Ordered, func() {
var ctx context.Context
var tempFolder string
var dbFilePath string
BeforeAll(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
var err error
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Close(ctx)
_ = os.RemoveAll(tempFolder)
})
// Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode.
dbFilePath = filepath.Join(tempFolder, "navidrome.db")
conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
// The previous container's cleanup closed the shared *sql.DB without
// dropping the singleton; force a fresh connection for this container.
singleton.DeleteInstance[*sql.DB]()
DeferCleanup(Init(ctx))
})
It("restores data into a database whose stale WAL sidecar files were left behind", func() {
By("seeding user data in the current database")
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred())
By("creating a backup containing the user row")
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
By("simulating the CLI exiting without closing the pool: sidecar files stay behind")
_, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)")
Expect(err).ToNot(HaveOccurred())
singleton.DeleteInstance[*sql.DB]()
err = tests.ClearDB()
Expect(err).ToNot(HaveOccurred())
By("restoring the backup")
Expect(Restore(ctx, path)).To(Succeed())
By("verifying the restored data is readable through a fresh connection")
singleton.DeleteInstance[*sql.DB]()
var userName string
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed())
Expect(userName).To(Equal("drilladmin"))
})
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
By("seeding user data in the current database")
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred())
By("attempting a restore from a nonexistent file")
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
err = Restore(ctx, missingPath)
Expect(err).To(HaveOccurred())
By("verifying the database was not wiped")
var userName string
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
Expect(userName).To(Equal("keepme"))
_, statErr := os.Stat(missingPath)
Expect(statErr).To(MatchError(os.ErrNotExist))
})
})
})
+14 -3
View File
@@ -157,13 +157,24 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool {
return l.numPending > 0
}
// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the
// database has ever been migrated.
func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) {
var name string
err := db.QueryRowContext(ctx,
"SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func isSchemaEmpty(ctx context.Context, db *sql.DB) bool {
rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
found, err := hasGooseTable(ctx, db)
if err != nil {
log.Fatal(ctx, "Database could not be opened!", err)
}
defer rows.Close()
return !rows.Next()
return !found
}
type logAdapter struct {
+4
View File
@@ -2,6 +2,10 @@ package db
// Definitions for testing private methods
var (
EmbedMigrations = embedMigrations
FTSTables = ftsTables
FTSTriggerSuffixes = ftsTriggerSuffixes
FTSSearchMigration = ftsSearchMigration
IsSchemaEmpty = isSchemaEmpty
BackupPath = backupPath
OptimizeDBAt = optimizeAt
@@ -3,7 +3,6 @@ package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/pressly/goose/v3"
@@ -28,10 +27,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
return err
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
insert into library(id, name, path) values(1, 'Music Library', '%s');
delete from property where id like 'LastScan-%%';
`, conf.Server.MusicFolder))
_, err = tx.ExecContext(ctx, `
insert into library(id, name, path) values(1, 'Music Library', ?);
delete from property where id like 'LastScan-%';
`, conf.Server.MusicFolder)
if err != nil {
return err
}
+367
View File
@@ -0,0 +1,367 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
)
var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"}
var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"}
// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the
// pragma for one extra row, because it truncates without emitting any marker.
const integrityCheckMaxIssues = 100
// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or
// an empty slice when healthy. The second value marks a list that was cut short.
func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) {
rows, err := database.QueryContext(ctx,
fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1))
if err != nil {
return nil, false, fmt.Errorf("running integrity_check: %w", err)
}
defer rows.Close()
var issues []string
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
issues = append(issues, line)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
if len(issues) == 1 && issues[0] == "ok" {
return nil, false, nil
}
if len(issues) > integrityCheckMaxIssues {
return issues[:integrityCheckMaxIssues], true, nil
}
return issues, false, nil
}
// FKViolation counts the rows in Table that reference missing rows in Parent.
type FKViolation struct {
Table string
Parent string
Count int64
}
// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair
// because the raw pragma emits one row per orphan, unbounded on a large library.
func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) {
rows, err := database.QueryContext(ctx,
`SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`)
if err != nil {
return nil, fmt.Errorf("running foreign_key_check: %w", err)
}
defer rows.Close()
var violations []FKViolation
for rows.Next() {
var v FKViolation
if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
violations = append(violations, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
return violations, nil
}
// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the
// FTS5 search tables, meaning RebuildFTS can fully repair the database.
func IsFTSCorruptionOnly(issues []string) bool {
if len(issues) == 0 {
return false
}
for _, line := range issues {
if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) {
return false
}
}
return true
}
// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a
// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database.
func VerifyFTS(ctx context.Context, database *sql.DB) error {
return verifyFTS(ctx, database)
}
func verifyFTS(ctx context.Context, database execer) error {
for _, table := range ftsTables {
stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list
if _, err := database.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("verifying %s: %w", table, err)
}
}
return nil
}
const ftsSearchMigration int64 = 20260220173400
var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first")
// requireFTSMigration fails unless the FTS search migration has run. The goose table
// is probed separately because a query against a missing table fails at prepare time.
func requireFTSMigration(ctx context.Context, database *sql.DB) error {
migrated, err := hasGooseTable(ctx, database)
if err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if !migrated {
return errNotMigrated
}
var applied int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if applied == 0 {
return errNotMigrated
}
return nil
}
// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the
// base tables, and verifies the result before committing. The tables are contentless,
// so no user data is lost. It needs only the FTS migration, not a fully migrated
// schema, because a corrupted DB often cannot run pending migrations.
func RebuildFTS(ctx context.Context, database *sql.DB) error {
if err := requireFTSMigration(ctx, database); err != nil {
return err
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("starting FTS rebuild transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
var stmts []string
for _, table := range ftsTables {
for _, suffix := range ftsTriggerSuffixes {
stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix)
}
stmts = append(stmts, "DROP TABLE IF EXISTS "+table)
}
stmts = append(stmts, ftsSchemaDDL...)
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("rebuilding FTS schema: %w", err)
}
}
if err := verifyFTS(ctx, tx); err != nil {
return fmt.Errorf("the rebuilt search index did not verify: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing FTS rebuild: %w", err)
}
return nil
}
// ftsSchemaDDL must reproduce what the full migration chain produces, not what any
// single migration does; the schema comparison in repair_test.go guards the drift.
var ftsSchemaDDL = []string{
`
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
name, sort_artist_name, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
SELECT rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
COALESCE(search_normalized, '')
FROM media_file
`,
`
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
COALESCE(search_participants, ''), COALESCE(discs, ''),
COALESCE(catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(album.tags, '$.albumversion') AS je), ''),
COALESCE(search_normalized, '')
FROM album
`,
`
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
FROM artist
`,
`
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
WHEN
OLD.title IS NOT NEW.title OR
OLD.album IS NOT NEW.album OR
OLD.artist IS NOT NEW.artist OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.sort_title IS NOT NEW.sort_title OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.discs IS NOT NEW.discs OR
OLD.catalog_num IS NOT NEW.catalog_num OR
OLD.tags IS NOT NEW.tags OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
}
+309
View File
@@ -0,0 +1,309 @@
package db_test
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/navidrome/navidrome/db"
"github.com/pressly/goose/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// newDB returns an in-memory database migrated up to the given goose version
// (0 = fully migrated).
func newDB(ctx context.Context, upTo int64) *sql.DB {
GinkgoHelper()
d, err := sql.Open(db.Dialect, "file::memory:")
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB
DeferCleanup(func() { _ = d.Close() })
_, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off")
Expect(err).ToNot(HaveOccurred())
goose.SetBaseFS(db.EmbedMigrations)
goose.SetLogger(goose.NopLogger())
DeferCleanup(func() { goose.SetBaseFS(nil) })
Expect(goose.SetDialect(db.Dialect)).To(Succeed())
if upTo == 0 {
Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed())
} else {
Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed())
}
return d
}
// openMismatchedIndexDB builds a database whose index is declared over a different
// column than the one it was populated from, so integrity_check reports one issue per row.
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
GinkgoHelper()
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
open := func() *sql.DB {
d, err := sql.Open(db.Dialect, path)
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1)
return d
}
d := open()
for _, stmt := range []string{
`create table t(a, b)`,
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
insert into t select x, x + 10000 from s`, rows),
`create index i on t(a)`,
`pragma writable_schema=on`,
`update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`,
} {
_, err := d.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
d = open()
DeferCleanup(func() { _ = d.Close() })
return d
}
var _ = Describe("IsFTSCorruptionOnly", func() {
It("is true when every issue mentions an FTS search table", func() {
Expect(db.IsFTSCorruptionOnly([]string{
`fts5: corruption found reading blob 42 from table "media_file_fts"`,
`malformed inverted index for FTS5 table main.album_fts`,
`fts5: corruption in "artist_fts"`,
})).To(BeTrue())
})
It("is false when any issue is outside the FTS search tables", func() {
Expect(db.IsFTSCorruptionOnly([]string{
`fts5: corruption found reading blob 42 from table "media_file_fts"`,
`*** in database main ***`,
})).To(BeFalse())
})
It("is false when there are no issues", func() {
Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse())
})
})
var _ = Describe("RebuildFTS schema guard", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
})
It("refuses to run on a schema older than the FTS migration", func() {
old := newDB(ctx, db.FTSSearchMigration-1)
err := db.RebuildFTS(ctx, old)
Expect(err).To(MatchError(ContainSubstring("migration")))
})
It("refuses to run on a database that was never migrated", func() {
empty, err := sql.Open(db.Dialect, "file::memory:")
Expect(err).ToNot(HaveOccurred())
empty.SetMaxOpenConns(1)
DeferCleanup(func() { _ = empty.Close() })
Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once")))
})
It("runs on a post-FTS schema even when newer migrations are pending", func() {
behind := newDB(ctx, 20260702152457)
Expect(db.RebuildFTS(ctx, behind)).To(Succeed())
})
})
var _ = Describe("Repair", func() {
var (
ctx context.Context
database *sql.DB
)
BeforeEach(func() {
ctx = context.Background()
database = newDB(ctx, 0)
for _, stmt := range []string{
`insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`,
`insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`,
`insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`,
`insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
})
corruptFTS := func(table string) {
// 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record
_, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
searchFTS := func(table, term string) int {
var count int
err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count)
Expect(err).ToNot(HaveOccurred())
return count
}
// ftsSchema returns name -> whitespace-normalized DDL for the FTS tables,
// their shadow tables, and their triggers.
ftsSchema := func() map[string]string {
rows, err := database.QueryContext(ctx,
`select name, sql from sqlite_master where name like '%_fts%' and sql is not null`)
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
ws := regexp.MustCompile(`\s+`)
schema := map[string]string{}
for rows.Next() {
var name, ddl string
Expect(rows.Scan(&name, &ddl)).To(Succeed())
schema[name] = ws.ReplaceAllString(ddl, " ")
}
Expect(rows.Err()).ToNot(HaveOccurred())
return schema
}
Describe("IntegrityCheck", func() {
It("returns no issues for a healthy database", func() {
issues, truncated, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(BeEmpty())
Expect(truncated).To(BeFalse())
})
It("reports corruption in an FTS index", func() {
corruptFTS("media_file_fts")
issues, truncated, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).ToNot(BeEmpty())
Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts"))
Expect(truncated).To(BeFalse())
})
It("flags the issue list as truncated when there are more issues than the limit", func() {
broken := openMismatchedIndexDB(ctx, 300)
issues, truncated, err := db.IntegrityCheck(ctx, broken)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(HaveLen(100))
Expect(truncated).To(BeTrue())
})
It("does not flag truncation when the issues exactly fill the limit", func() {
broken := openMismatchedIndexDB(ctx, 100)
issues, truncated, err := db.IntegrityCheck(ctx, broken)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(HaveLen(100))
Expect(truncated).To(BeFalse())
})
})
Describe("ForeignKeyCheck", func() {
It("returns no violations for a healthy database", func() {
violations, err := db.ForeignKeyCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(violations).To(BeEmpty())
})
It("reports rows referencing missing parents", func() {
_, err := database.ExecContext(ctx,
`insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`)
Expect(err).ToNot(HaveOccurred())
violations, err := db.ForeignKeyCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(violations).To(HaveLen(1))
Expect(violations[0].Table).To(Equal("media_file"))
Expect(violations[0].Parent).To(Equal("library"))
Expect(violations[0].Count).To(BeNumerically("==", 1))
})
})
Describe("VerifyFTS", func() {
It("passes on a healthy index", func() {
Expect(db.VerifyFTS(ctx, database)).To(Succeed())
})
It("fails on a corrupted index, naming the table", func() {
corruptFTS("album_fts")
Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts")))
})
})
Describe("RebuildFTS", func() {
It("repairs a corrupted FTS index", func() {
corruptFTS("media_file_fts")
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
issues, _, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(BeEmpty())
Expect(db.VerifyFTS(ctx, database)).To(Succeed())
Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1))
Expect(searchFTS("album_fts", "russia")).To(Equal(1))
Expect(searchFTS("artist_fts", "ramones")).To(Equal(1))
})
It("recreates tables and triggers dropped by hand", func() {
for _, table := range db.FTSTables {
for _, suffix := range db.FTSTriggerSuffixes {
_, err := database.ExecContext(ctx, "drop trigger "+table+suffix)
Expect(err).ToNot(HaveOccurred())
}
_, err := database.ExecContext(ctx, "drop table "+table)
Expect(err).ToNot(HaveOccurred())
}
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1))
})
It("rolls back and keeps the old index when the rebuild fails", func() {
// Triggers go first: SQLite refuses to drop a column they reference.
for _, suffix := range db.FTSTriggerSuffixes {
_, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix)
Expect(err).ToNot(HaveOccurred())
}
_, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`)
Expect(err).ToNot(HaveOccurred())
Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed())
Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1))
Expect(searchFTS("album_fts", "russia")).To(Equal(1))
})
It("produces the same schema as the migration", func() {
migrated := ftsSchema()
Expect(migrated).ToNot(BeEmpty())
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
Expect(ftsSchema()).To(Equal(migrated))
})
It("leaves working triggers behind", func() {
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
_, err := database.ExecContext(ctx,
`insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`)
Expect(err).ToNot(HaveOccurred())
Expect(searchFTS("artist_fts", "blondie")).To(Equal(1))
_, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`)
Expect(err).ToNot(HaveOccurred())
Expect(searchFTS("artist_fts", "blondie")).To(BeZero())
})
})
})
+17 -17
View File
@@ -3,11 +3,11 @@ module github.com/navidrome/navidrome
go 1.27
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec
require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.4
github.com/andybalholm/cascadia v1.3.5
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -26,7 +26,7 @@ require (
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gohugoio/hashstructure v1.0.0
github.com/gohugoio/hashstructure v1.1.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
@@ -35,16 +35,16 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/kardianos/service v1.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.2.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/lestrrat-go/jwx/v3 v3.3.0
github.com/mattn/go-sqlite3 v1.14.52
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.1
github.com/onsi/ginkgo/v2 v2.32.2
github.com/onsi/gomega v1.43.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.3
github.com/pressly/goose/v3 v3.28.0
github.com/prometheus/client_golang v1.24.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@@ -60,13 +60,13 @@ require (
github.com/zeebo/xxh3 v1.1.0
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.45.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -114,7 +114,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
@@ -131,8 +131,8 @@ require (
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
+44 -44
View File
@@ -6,8 +6,8 @@ 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/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
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/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
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=
@@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -94,8 +94,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -134,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
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/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -159,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -185,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
@@ -199,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -304,34 +304,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
@@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+4 -3
View File
@@ -32,9 +32,6 @@ type Share struct {
func (s Share) CoverArtID() ArtworkID {
ids := strings.SplitN(s.ResourceIDs, ",", 2)
if len(ids) == 0 {
return ArtworkID{}
}
switch s.ResourceType {
case "album":
return Album{ID: ids[0]}.CoverArtID()
@@ -43,6 +40,10 @@ func (s Share) CoverArtID() ArtworkID {
case "artist":
return Artist{ID: ids[0]}.CoverArtID()
}
// Tracks can be empty when they went missing or the owner lost access to their library.
if len(s.Tracks) == 0 {
return ArtworkID{}
}
rnd := random.Int64N(len(s.Tracks))
return s.Tracks[rnd].CoverArtID()
}
+19
View File
@@ -0,0 +1,19 @@
package model_test
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Share.CoverArtID", func() {
It("returns an empty artwork ID for a media file share with no visible tracks", func() {
s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"}
Expect(s.CoverArtID()).To(Equal(model.ArtworkID{}))
})
It("picks a track's cover for a media file share", func() {
s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}}
Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID()))
})
})
+8 -14
View File
@@ -72,7 +72,6 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e
}
func (r *shareRepository) loadMedia(share *model.Share) error {
var err error
ids := strings.Split(share.ResourceIDs, ",")
if len(ids) == 0 {
return nil
@@ -80,15 +79,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
noMissing := func(cond Sqlizer) Sqlizer {
return And{cond, Eq{"missing": false}}
}
// Load as the share owner so their library access is applied, whoever renders the share.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
switch share.ResourceType {
case "artist":
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
// Load as the share owner so their library access is applied.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
albumRepo := NewAlbumRepository(ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"})
if err != nil {
@@ -98,20 +97,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"})
return err
case "album":
albumRepo := NewAlbumRepository(r.ctx, r.db)
albumRepo := NewAlbumRepository(ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})})
if err != nil {
return err
}
mfRepo := NewMediaFileRepository(r.ctx, r.db)
mfRepo := NewMediaFileRepository(ctx, r.db)
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"})
return err
case "playlist":
// Load tracks as the share owner so their library access is applied.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
plsRepo := NewPlaylistRepository(ctx, r.db)
// Tracks returns nil when the playlist is no longer visible to the owner
// (e.g. it was made private after the share was created); leave the share
@@ -127,7 +121,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
share.Tracks = tracks.MediaFiles()
return nil
case "media_file":
mfRepo := NewMediaFileRepository(r.ctx, r.db)
mfRepo := NewMediaFileRepository(ctx, r.db)
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})})
share.Tracks = sortByIdPosition(tracks, ids)
return err
+33 -10
View File
@@ -228,7 +228,7 @@ var _ = Describe("ShareRepository", func() {
})
})
Describe("Artist share library scoping", func() {
Describe("Artist, album and media file share library scoping", func() {
var otherLib model.Library
var owner model.User
const primaryID = "share-aa-primary"
@@ -267,20 +267,26 @@ var _ = Describe("ShareRepository", func() {
Expect(ur.Put(&owner)).To(Succeed())
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": "art-share", "user": owner.ID, "desc": "Artist scope share",
"type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
for _, s := range []struct{ id, typ, ids string }{
{"art-share", "artist", secondaryID},
{"art-album-share", "album", "art-album-ok,art-album-other"},
{"art-mf-share", "media_file", "art-ok,art-other"},
} {
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": s.id, "user": owner.ID, "desc": "Scope share",
"type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
}
})
AfterEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
_, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute()
_, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute()
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
alr := NewAlbumRepository(adminCtx, b).(*albumRepository)
@@ -309,6 +315,23 @@ var _ = Describe("ShareRepository", func() {
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")),
"an album outside the owner's libraries must not appear in the share")
})
It("excludes albums and their tracks outside the owner's libraries from an album share", func() {
// Public share rendering has no user in the context.
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")))
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")))
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
It("excludes tracks outside the owner's libraries from a media file share", func() {
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
})
Describe("Ownership Checks", func() {
+1 -1
View File
@@ -1 +1 @@
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 .
+19 -8
View File
@@ -93,7 +93,8 @@
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen"
"share": "Freigabe erstellen",
"refresh": ""
},
"lists": {
"all": "Alle",
@@ -155,11 +156,13 @@
"newPassword": "Neues Passwort",
"token": "Token",
"lastAccessAt": "Letzter Zugriff am",
"libraries": "Bibliotheken"
"libraries": "Bibliotheken",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken"
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
"scrobbleFilter": ""
},
"notifications": {
"created": "Benutzer erstellt",
@@ -173,7 +176,8 @@
"adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken"
},
"validation": {
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein"
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
"invalidScrobbleFilter": ""
}
},
"player": {
@@ -196,6 +200,9 @@
"targetFormat": "Zielformat",
"defaultBitRate": "Standardbitrate",
"command": "Befehl"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -210,7 +217,8 @@
"songCount": "Titelanzahl",
"comment": "Kommentar",
"sync": "Auto-Import",
"path": "Importieren aus"
"path": "Importieren aus",
"starred": "Favorit"
},
"actions": {
"selectPlaylist": "Wiedergabeliste auswählen:",
@@ -403,7 +411,8 @@
"requiredHosts": "Benötigte Hosts",
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
"idHelp": ""
},
"placeholders": {
"configKey": "Schlüssel",
@@ -599,7 +608,8 @@
"coverUploaded": "Cover aktualisiert",
"coverRemoved": "Cover entfernt",
"coverUploadError": "Fehler beim Hochladen des Covers",
"coverRemoveError": "Fehler beim Entfernen des Covers"
"coverRemoveError": "Fehler beim Entfernen des Covers",
"metadataRefreshStarted": ""
},
"menu": {
"library": "Bibliothek",
@@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} von %{total} Bibliotheken",
"selectLibraries": "Bibliotheken auswählen",
"none": "Keine"
}
},
"onlyFavourites": "Nur Favoriten anzeigen"
},
"player": {
"playListsText": "Warteschlange abspielen",
+22 -9
View File
@@ -38,7 +38,9 @@
"missing": "Απών",
"libraryName": "Βιβλιοθήκη",
"composer": "Συνθέτης",
"disc": "Δίσκος %{discNumber}"
"disc": "Δίσκος %{discNumber}",
"albumGain": "Κέρδος άλμπουμ",
"trackGain": "Κέρδος παρακολούθησης"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@@ -91,7 +93,8 @@
"addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης",
"download": "Ληψη",
"info": "Εμφάνιση Πληροφοριών",
"share": "Μερίδιο"
"share": "Μερίδιο",
"refresh": "Ανανέωση μεταδεδομένων"
},
"lists": {
"all": "Όλα",
@@ -153,11 +156,13 @@
"newPassword": "Νέος Κωδικός Πρόσβασης",
"token": "Token",
"lastAccessAt": "Τελευταία Πρόσβαση",
"libraries": "Βιβλιοθήκες"
"libraries": "Βιβλιοθήκες",
"scrobbleFilter": "Φίλτρο Scrobble"
},
"helperTexts": {
"name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση",
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη"
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη",
"scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}."
},
"notifications": {
"created": "Ο χρήστης δημιουργήθηκε",
@@ -171,7 +176,8 @@
"adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες"
},
"validation": {
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές"
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές",
"invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης."
}
},
"player": {
@@ -194,6 +200,9 @@
"targetFormat": "Μορφη Προορισμου",
"defaultBitRate": "Προκαθορισμένος Ρυθμός Bit",
"command": "Εντολή"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -208,7 +217,8 @@
"songCount": "Τραγούδια",
"comment": "Σχόλιο",
"sync": "Αυτόματη εισαγωγή",
"path": "Εισαγωγή από"
"path": "Εισαγωγή από",
"starred": "Ευνοούμενος"
},
"actions": {
"selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:",
@@ -401,7 +411,8 @@
"requiredHosts": "Απαιτούμενοι hosts",
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση."
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.",
"idHelp": ""
},
"placeholders": {
"configKey": "κλειδί",
@@ -597,7 +608,8 @@
"coverUploaded": "Το εξώφυλλο ενημερώθηκε",
"coverRemoved": "Το εξώφυλλο αφαιρέθηκε",
"coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου",
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου"
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου",
"metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο"
},
"menu": {
"library": "Βιβλιοθήκη",
@@ -632,7 +644,8 @@
"multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες",
"selectLibraries": "Επιλέξτε βιβλιοθήκες",
"none": "Κανένα"
}
},
"onlyFavourites": "Εμφάνιση μόνο αγαπημένων"
},
"player": {
"playListsText": "Ουρά Αναπαραγωγής",
+19 -8
View File
@@ -93,7 +93,8 @@
"addToPlaylist": "Lisää soittolistaan",
"download": "Lataa",
"info": "Info",
"share": "Jaa"
"share": "Jaa",
"refresh": "Päivitä metatiedot"
},
"lists": {
"all": "Kaikki",
@@ -155,11 +156,13 @@
"newPassword": "Uusi salasana",
"token": "Avain",
"lastAccessAt": "Viimeisin käyttö",
"libraries": "Kirjastot"
"libraries": "Kirjastot",
"scrobbleFilter": "Scrobble-suodatin"
},
"helperTexts": {
"name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään",
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja"
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja",
"scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta."
},
"notifications": {
"created": "Käyttäjä luotu",
@@ -173,7 +176,8 @@
"adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin"
},
"validation": {
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille"
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille",
"invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä."
}
},
"player": {
@@ -196,6 +200,9 @@
"targetFormat": "Kohde formaatti",
"defaultBitRate": "Oletus bittinopeus",
"command": "Komento"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -210,7 +217,8 @@
"songCount": "Kappaleita",
"comment": "Kommentti",
"sync": "Automaattinen tuonti",
"path": "Tuo"
"path": "Tuo",
"starred": "Suosikki"
},
"actions": {
"selectPlaylist": "Valitse soittolista:",
@@ -403,7 +411,8 @@
"requiredHosts": "Vaaditut palvelimet",
"configValidationError": "Määrityksen validointi epäonnistui:",
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.",
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus."
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.",
"idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa."
},
"placeholders": {
"configKey": "avain",
@@ -599,7 +608,8 @@
"coverUploaded": "Kansikuva päivitetty",
"coverRemoved": "Kansikuva poistettu",
"coverUploadError": "Virhe ladattaessa kansikuvaa",
"coverRemoveError": "Virhe poistettaessa kansikuvaa"
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
},
"menu": {
"library": "Kirjasto",
@@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} / %{total} kirjastoa",
"selectLibraries": "Valitse kirjastot",
"none": "Ei mitään"
}
},
"onlyFavourites": "Näytä vain suosikit"
},
"player": {
"playListsText": "Jono",
+22 -11
View File
@@ -93,7 +93,8 @@
"addToPlaylist": "Engadir a Lista",
"download": "Descargar",
"info": "Obter info",
"share": "Compartir"
"share": "Compartir",
"refresh": "Actualizar metadatos"
},
"lists": {
"all": "Todo",
@@ -155,11 +156,13 @@
"newPassword": "Novo contrasinal",
"token": "Token",
"lastAccessAt": "Último acceso",
"libraries": "Bibliotecas"
"libraries": "Bibliotecas",
"scrobbleFilter": "Filtro para scrobble"
},
"helperTexts": {
"name": "Os cambios no nome aplicaranse a próxima vez que accedas",
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto",
"scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais."
},
"notifications": {
"created": "Creouse a usuaria",
@@ -173,7 +176,8 @@
"adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
},
"validation": {
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins",
"invalidScrobbleFilter": ""
}
},
"player": {
@@ -196,6 +200,9 @@
"targetFormat": "Formato de destino",
"defaultBitRate": "Taxa de bit por defecto",
"command": "Orde"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -210,7 +217,8 @@
"songCount": "Cancións",
"comment": "Comentario",
"sync": "Autoimportación",
"path": "Importar desde"
"path": "Importar desde",
"starred": "Favorita"
},
"actions": {
"selectPlaylist": "Elixe unha lista:",
@@ -403,7 +411,8 @@
"requiredHosts": "Servidores requeridos",
"configValidationError": "Fallou a comprobación da configuración:",
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.",
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura."
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.",
"idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents."
},
"placeholders": {
"configKey": "clave",
@@ -435,7 +444,7 @@
"minValue": "Ten que ter polo menos %{min}",
"maxValue": "Ten que ter %{max} ou menos",
"number": "Ten que ser un número",
"email": "Ten que ser un email válido",
"email": "Ten que ser un correo válido",
"oneOf": "Ten que ser un de: %{options}",
"regex": "Ten que ter un formato específico (regexp): %{pattern}",
"unique": "Ten que ser único",
@@ -509,7 +518,7 @@
}
},
"message": {
"about": "Acerca de",
"about": "Sobre",
"are_you_sure": "Tes certeza?",
"bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?",
"bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}",
@@ -599,7 +608,8 @@
"coverUploaded": "Subiuse a capa",
"coverRemoved": "Retirouse a capa",
"coverUploadError": "Erro ao subir a capa",
"coverRemoveError": "Erro ao retirar a capa"
"coverRemoveError": "Erro ao retirar a capa",
"metadataRefreshStarted": "Actualizar en segundo plano os metadatos"
},
"menu": {
"library": "Biblioteca",
@@ -626,7 +636,7 @@
}
},
"albumList": "Álbums",
"about": "Acerca de",
"about": "Sobre",
"playlists": "Listas de reprodución",
"sharedPlaylists": "Listas compartidas",
"librarySelector": {
@@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
"selectLibraries": "Seleccionar Bibliotecas",
"none": "Ningunha"
}
},
"onlyFavourites": "Mostrar só as favoritas"
},
"player": {
"playListsText": "Reproducir cola",
+34 -11
View File
@@ -37,7 +37,10 @@
"sampleRate": "Częstotliwość próbkowania",
"missing": "Brak",
"libraryName": "Biblioteka",
"composer": "Kompozytor"
"composer": "Kompozytor",
"disc": "Dysk %{discNumber}",
"albumGain": "Wzmocnienie albumu",
"trackGain": "Wzmocnienie utworu"
},
"actions": {
"addToQueue": "Odtwarzaj Później",
@@ -90,7 +93,8 @@
"addToPlaylist": "Dodaj do Playlisty",
"download": "Pobierz",
"info": "Zdobądź Informacje",
"share": "Udostępnij"
"share": "Udostępnij",
"refresh": "Odśwież Metadane"
},
"lists": {
"all": "Wszystkie",
@@ -152,11 +156,13 @@
"newPassword": "Nowe hasło",
"token": "Token",
"lastAccessAt": "Ostatnia Aktywność",
"libraries": "Biblioteki"
"libraries": "Biblioteki",
"scrobbleFilter": "Filtr scrobblowania"
},
"helperTexts": {
"name": "Zmiana nazwy będzie widoczna przy następnym logowaniu",
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki"
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki",
"scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń."
},
"notifications": {
"created": "Dodano użytkownika",
@@ -170,7 +176,8 @@
"adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek"
},
"validation": {
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika"
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika",
"invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane."
}
},
"player": {
@@ -193,6 +200,9 @@
"targetFormat": "Format Docelowy",
"defaultBitRate": "Domyślny Bit Rate",
"command": "Komenda"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -207,7 +217,8 @@
"songCount": "Liczba utworów",
"comment": "Komentarz",
"sync": "Import automatyczny",
"path": "Zaimportuj z"
"path": "Zaimportuj z",
"starred": "Ulubione"
},
"actions": {
"selectPlaylist": "Wybierz playlistę:",
@@ -353,7 +364,8 @@
"allUsers": "Zezwalaj wszystkim użytkownikom",
"selectedUsers": "Wybrani użytkownicy",
"allLibraries": "Zezwalaj dla wszystkich bibliotek",
"selectedLibraries": "Wybrane biblioteki"
"selectedLibraries": "Wybrane biblioteki",
"allowWriteAccess": "Zezwól na zapis"
},
"sections": {
"status": "Status",
@@ -398,7 +410,9 @@
"librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.",
"requiredHosts": "Wymagane hosty",
"configValidationError": "Weryfikacja konfiguracji nie powiodła się:",
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy."
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.",
"allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.",
"idHelp": ""
},
"placeholders": {
"configKey": "klucz",
@@ -588,7 +602,14 @@
"remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
"noSimilarSongsFound": "Brak podobnych utworów",
"noTopSongsFound": "Brak najlepszych utworów",
"startingInstantMix": "Ładowanie Natychmiastowego Miksu..."
"startingInstantMix": "Ładowanie Natychmiastowego Miksu...",
"uploadCover": "Prześlij Okładkę",
"removeCover": "Usuń Okładkę",
"coverUploaded": "Okładka zaktualizowana",
"coverRemoved": "Okładka usunięta",
"coverUploadError": "Błąd przesyłania okładki",
"coverRemoveError": "Błąd usuwania okładki",
"metadataRefreshStarted": "Odświeżanie metadanych w tle"
},
"menu": {
"library": "Biblioteka",
@@ -623,7 +644,8 @@
"multipleLibraries": "%{selected} z %{total} Bibliotek",
"selectLibraries": "Wybierz Biblioteki",
"none": "Żadna"
}
},
"onlyFavourites": "Pokaż tylko ulubione"
},
"player": {
"playListsText": "Kolejka Odtwarzania",
@@ -674,7 +696,8 @@
"exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML",
"exportFailed": "Błąd kopiowania konfiguracji",
"devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)",
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach"
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach",
"downloadToml": "Konfiguracja Pobierania (TOML)"
}
},
"activity": {
+14 -9
View File
@@ -35,12 +35,12 @@
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
"albumGain": "Ganho do álbum",
"trackGain": "Ganho da faixa",
"missing": "Ausente",
"libraryName": "Biblioteca",
"composer": "Compositor",
"disc": "Disco %{discNumber}"
"disc": "Disco %{discNumber}",
"albumGain": "Ganho do álbum",
"trackGain": "Ganho da faixa"
},
"actions": {
"addToQueue": "Adicionar à fila",
@@ -93,8 +93,8 @@
"addToPlaylist": "Adicionar à playlist",
"download": "Baixar",
"info": "Detalhes",
"refresh": "Atualizar Metadados",
"share": "Compartilhar"
"share": "Compartilhar",
"refresh": "Atualizar Metadados"
},
"lists": {
"all": "Todos",
@@ -200,6 +200,9 @@
"targetFormat": "Formato",
"defaultBitRate": "Bitrate padrão",
"command": "Comando"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -214,7 +217,8 @@
"songCount": "Músicas",
"comment": "Comentário",
"sync": "Auto-importar",
"path": "Importar de"
"path": "Importar de",
"starred": "Favorita"
},
"actions": {
"selectPlaylist": "Selecione a playlist:",
@@ -394,7 +398,6 @@
"invalidJson": "A configuração deve ser um JSON válido"
},
"messages": {
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.",
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
"clickPermissions": "Clique em uma permissão para ver detalhes",
"noConfig": "Nenhuma configuração definida",
@@ -408,7 +411,8 @@
"requiredHosts": "Hosts necessários",
"configValidationError": "Falha na validação da configuração:",
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura."
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents."
},
"placeholders": {
"configKey": "chave",
@@ -640,7 +644,8 @@
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
"selectLibraries": "Selecionar Bibliotecas",
"none": "Nenhuma"
}
},
"onlyFavourites": "Somente favoritas"
},
"player": {
"playListsText": "Fila de Execução",
+19 -8
View File
@@ -93,7 +93,8 @@
"addToPlaylist": "เพิ่มลงในเพลย์ลิสต์",
"download": "ดาวน์โหลด",
"info": "ดูรายละเอียด",
"share": "แบ่งปัน"
"share": "แบ่งปัน",
"refresh": ""
},
"lists": {
"all": "ทั้งหมด",
@@ -155,11 +156,13 @@
"newPassword": "รหัสผ่านใหม่",
"token": "โทเคน",
"lastAccessAt": "เข้าใช้ล่าสุด",
"libraries": "ห้องสมุด"
"libraries": "ห้องสมุด",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น",
"scrobbleFilter": ""
},
"notifications": {
"created": "สร้างชื่อผู้ใช้",
@@ -173,7 +176,8 @@
"adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
},
"validation": {
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล",
"invalidScrobbleFilter": ""
}
},
"player": {
@@ -196,6 +200,9 @@
"targetFormat": "ชนิดไฟล์เสียง",
"defaultBitRate": "บิตเรท",
"command": "คำสั่ง"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -210,7 +217,8 @@
"songCount": "เพลง",
"comment": "ความคิดเห็น",
"sync": "นำเข้าอัตโนมัติ",
"path": "นำเข้าจาก"
"path": "นำเข้าจาก",
"starred": "ชื่นชอบ"
},
"actions": {
"selectPlaylist": "เลือกเพลย์ลิสต์",
@@ -403,7 +411,8 @@
"requiredHosts": "ต้องการ Host",
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น"
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น",
"idHelp": ""
},
"placeholders": {
"configKey": "คีย์",
@@ -599,7 +608,8 @@
"coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว",
"coverRemoved": "ภาพหน้าปกถูกลบแล้ว",
"coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด",
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด"
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด",
"metadataRefreshStarted": ""
},
"menu": {
"library": "ห้องสมุดเพลง",
@@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
"selectLibraries": "เลือกห้องสมุด",
"none": "ไม่มี"
}
},
"onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ"
},
"player": {
"playListsText": "คิวเล่น",
+22 -9
View File
@@ -38,7 +38,9 @@
"missing": "Поле відсутнє",
"libraryName": "Бібліотека",
"composer": "Композитор",
"disc": "Диск %{discNumber}"
"disc": "Диск %{discNumber}",
"albumGain": "",
"trackGain": ""
},
"actions": {
"addToQueue": "Прослухати пізніше",
@@ -91,7 +93,8 @@
"addToPlaylist": "Додати у список відтворення",
"download": "Завантажити",
"info": "Отримати інформацію",
"share": "Поширити"
"share": "Поширити",
"refresh": ""
},
"lists": {
"all": "Усі",
@@ -153,11 +156,13 @@
"newPassword": "Новий пароль",
"token": "Токен",
"lastAccessAt": "Останній доступ",
"libraries": "Бібліотеки"
"libraries": "Бібліотеки",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "Змінене ім'я буде відображатися при наступній авторизації",
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням"
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням",
"scrobbleFilter": ""
},
"notifications": {
"created": "Користувача створено",
@@ -171,7 +176,8 @@
"adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек"
},
"validation": {
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека"
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека",
"invalidScrobbleFilter": ""
}
},
"player": {
@@ -194,6 +200,9 @@
"targetFormat": "Цільовий формат",
"defaultBitRate": "Швидкість передачі бітів за замовчуванням",
"command": "Команда"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -208,7 +217,8 @@
"songCount": "Пісні",
"comment": "Коментар",
"sync": "Автоімпорт",
"path": "Імпортувати із"
"path": "Імпортувати із",
"starred": "Улюблене"
},
"actions": {
"selectPlaylist": "Вибрати список відтворення:",
@@ -401,7 +411,8 @@
"requiredHosts": "Обов'язкові хости",
"configValidationError": "Перевірка конфігурації зазнала невдачі:",
"schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.",
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання."
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.",
"idHelp": ""
},
"placeholders": {
"configKey": "ключ",
@@ -597,7 +608,8 @@
"coverUploaded": "Обкладинку оновлено",
"coverRemoved": "Обкладинка видалена",
"coverUploadError": "Помилка завантаження обкладинки",
"coverRemoveError": "Помилка видалення обкладинки"
"coverRemoveError": "Помилка видалення обкладинки",
"metadataRefreshStarted": ""
},
"menu": {
"library": "Бібліотека",
@@ -632,7 +644,8 @@
"multipleLibraries": "%{selected} з %{total} Бібліотеки",
"selectLibraries": "Вибір бібліотек",
"none": "Відсутня"
}
},
"onlyFavourites": "Показати улюблене"
},
"player": {
"playListsText": "Грати по черзі",
+19 -8
View File
@@ -93,7 +93,8 @@
"addToPlaylist": "加入至播放清單",
"download": "下載",
"info": "取得資訊",
"share": "分享"
"share": "分享",
"refresh": ""
},
"lists": {
"all": "所有",
@@ -155,11 +156,13 @@
"newPassword": "新密碼",
"token": "權杖",
"lastAccessAt": "上次存取",
"libraries": "媒體庫"
"libraries": "媒體庫",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "您的名稱會在下次登入時生效",
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫",
"scrobbleFilter": ""
},
"notifications": {
"created": "使用者已建立",
@@ -173,7 +176,8 @@
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫",
"invalidScrobbleFilter": ""
}
},
"player": {
@@ -196,6 +200,9 @@
"targetFormat": "目標格式",
"defaultBitRate": "預設位元率",
"command": "指令"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@@ -210,7 +217,8 @@
"songCount": "歌曲數",
"comment": "註解",
"sync": "自動匯入",
"path": "匯入來源"
"path": "匯入來源",
"starred": "收藏"
},
"actions": {
"selectPlaylist": "選取播放清單:",
@@ -403,7 +411,8 @@
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。",
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。"
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。",
"idHelp": ""
},
"placeholders": {
"configKey": "鍵",
@@ -599,7 +608,8 @@
"coverUploaded": "已更新封面圖",
"coverRemoved": "已移除封面圖",
"coverUploadError": "上傳封面圖時發生錯誤",
"coverRemoveError": "移除封面圖時發生錯誤"
"coverRemoveError": "移除封面圖時發生錯誤",
"metadataRefreshStarted": ""
},
"menu": {
"library": "媒體庫",
@@ -634,7 +644,8 @@
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
}
},
"onlyFavourites": "僅顯示收藏"
},
"player": {
"playListsText": "播放佇列",
+10
View File
@@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/singleton"
"golang.org/x/time/rate"
)
@@ -387,3 +388,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
s.broker.SendBroadcastMessage(ctx, status)
}
// GetInstance returns the scanner singleton: Status reads the progress counters of the controller
// running the scan, and scheduler, watcher and signal scans do not start from the API's injector.
func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker,
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
return singleton.GetInstance(func() *controller {
return New(rootCtx, ds, broker, pls, m).(*controller)
})
}
+10
View File
@@ -92,3 +92,13 @@ var _ = Describe("EffectiveFullScan", func() {
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse())
})
})
var _ = Describe("GetInstance", func() {
It("returns the same controller to every caller", func() {
ds := &tests.MockDataStore{}
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
Expect(a).To(BeIdenticalTo(b))
})
})
+11
View File
@@ -96,6 +96,16 @@ func buildAuthPayload(user *model.User) map[string]any {
return payload
}
// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs.
const MaxLoginBodySize = 8 << 10
func LimitLoginBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize)
next.ServeHTTP(w, r)
})
}
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
data := make(map[string]string)
decoder := json.NewDecoder(r.Body)
@@ -150,6 +160,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
return fmt.Errorf("creating initial user: %w", err)
}
return nil
}
+16
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -64,6 +65,14 @@ var _ = Describe("Auth", func() {
})
})
Describe("createAdminUser", func() {
It("returns the error when the user cannot be saved", func() {
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
err := createAdminUser(context.Background(), ds, "johndoe", "secret")
Expect(err).To(MatchError(ContainSubstring("db is down")))
})
})
Describe("Login from HTTP headers", func() {
const (
trustedIpv4 = "192.168.0.42"
@@ -200,6 +209,13 @@ var _ = Describe("Auth", func() {
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a request body larger than the limit", func() {
body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}`
req = httptest.NewRequest("POST", "/login", strings.NewReader(body))
LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req)
Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity))
})
It("logs in successfully if user exists", func() {
usr := ds.User(context.Background())
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
+4 -6
View File
@@ -7,7 +7,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"golang.org/x/sync/singleflight"
"github.com/navidrome/navidrome/conf"
@@ -78,13 +77,12 @@ func (api *Router) routes() http.Handler {
inner.Post("/system/ping", api.ping)
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
// brute-force surface, so it must share the same per-IP throttle when one is configured.
// brute-force surface, so it must share the same per-client throttle when one is configured.
login := inner.With(server.LimitLoginBody)
if conf.Server.AuthRequestLimit > 0 {
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
}
login.Post("/users/authenticatebyname", api.authenticateByName)
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
+23
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
@@ -84,4 +85,26 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusUnauthorized))
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
login := func(clientIP string) int {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Real-IP", clientIP)
handler.ServeHTTP(w, r)
return w.Code
}
Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized))
Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized))
})
})
+13
View File
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -101,3 +102,15 @@ var _ = Describe("AuthenticateByName", func() {
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
var _ = Describe("AuthenticateByName body limit", func() {
It("rejects a request body larger than the limit", func() {
ds := &tests.MockDataStore{}
api := &Router{ds: ds}
w := httptest.NewRecorder()
body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}`
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body))
server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
+2 -2
View File
@@ -34,8 +34,8 @@ func imageSize(maxWidth, maxHeight int) int {
}
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
// Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and
// upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user).
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
itemId, ok := itemIDParam(w, r, "itemId")
if !ok {
+1 -1
View File
@@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool {
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
+71 -11
View File
@@ -7,7 +7,9 @@ import (
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
})
}
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
// in the context as request.ReverseProxyIp.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
).Handler(next)
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
// The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients
// behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security
// impact of reading the headers from untrusted sources is limited.
return middleware.RealIP(next)
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
// exactly one hop.
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
})
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
+97
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -435,4 +436,100 @@ var _ = Describe("middlewares", func() {
})
})
})
Describe("realIPMiddleware", func() {
var resolved, remoteAddr string
var proxyIP any
next := func(w http.ResponseWriter, r *http.Request) {
resolved = middleware.GetClientIP(r.Context())
remoteAddr = r.RemoteAddr
proxyIP = r.Context().Value(request.ReverseProxyIp)
}
call := func(peer string, headers map[string]string) {
resolved, remoteAddr, proxyIP = "", "", nil
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
for k, v := range headers {
r.Header.Set(k, v)
}
realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r)
}
Context("without a trusted proxy", func() {
It("ignores client-supplied forwarding headers", func() {
call("10.0.0.1:1234", map[string]string{
"X-Forwarded-For": "203.0.113.5",
"X-Real-IP": "203.0.113.6",
"True-Client-IP": "203.0.113.7",
})
Expect(resolved).To(Equal("10.0.0.1"))
})
It("leaves RemoteAddr untouched", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(remoteAddr).To(Equal("10.0.0.1:1234"))
})
})
Context("with a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("uses the forwarded client IP when the peer is a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
Expect(resolved).To(Equal("203.0.113.5"))
Expect(remoteAddr).To(Equal("203.0.113.5"))
})
It("honours X-Real-IP from a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"})
Expect(resolved).To(Equal("203.0.113.6"))
})
It("ignores forwarding headers when the peer is not a trusted proxy", func() {
call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(resolved).To(Equal("198.51.100.9"))
Expect(remoteAddr).To(Equal("198.51.100.9:1234"))
})
It("keeps the peer address in the context for external auth", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(proxyIP).To(Equal("10.0.0.1:1234"))
})
})
})
Describe("ClientIPRateLimiter", func() {
var handler http.Handler
JustBeforeEach(func() {
handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })))
})
attempt := func(peer string, header, value string) int {
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
r.Header.Set(header, value)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
DescribeTable("keeps one bucket per peer when the forwarding header is rotated",
func(header string) {
Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests))
},
Entry("X-Forwarded-For", "X-Forwarded-For"),
Entry("X-Real-IP", "X-Real-IP"),
Entry("True-Client-IP", "True-Client-IP"),
)
Context("behind a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("gives each real client its own bucket", func() {
Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK))
})
})
})
})
+2 -2
View File
@@ -17,7 +17,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@@ -205,11 +204,12 @@ func (s *Server) initRoutes() {
func (s *Server) mountAuthenticationRoutes() chi.Router {
r := s.router
return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) {
r.Use(LimitLoginBody)
if conf.Server.AuthRequestLimit > 0 {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
r.With(rateLimiter).Post("/login", login(s.ds))
} else {
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
@@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
})
})
var _ = Describe("Sharing Downloadable Default", func() {
var albumID string
BeforeEach(func() {
conf.Server.EnableSharing = true
setupTestDB()
conf.Server.EnableDownloads = true
albumID = albumIDByName("Abbey Road")
})
createShare := func(params ...string) *model.Share {
GinkgoHelper()
resp := doReq("createShare", append([]string{"id", albumID}, params...)...)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares.Share).To(HaveLen(1))
share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID)
Expect(err).ToNot(HaveOccurred())
return share
}
DescribeTable("createShare resolves downloadable",
func(defaultDownloadable, enableDownloads bool, params []string, expected bool) {
conf.Server.DefaultDownloadableShare = defaultDownloadable
conf.Server.EnableDownloads = enableDownloads
Expect(createShare(params...).Downloadable).To(Equal(expected))
},
Entry("applies the default when the param is absent", true, true, nil, true),
Entry("stays off when the default is off", false, true, nil, false),
Entry("ignores the default when downloads are disabled", true, false, nil, false),
Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false),
Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true),
)
It("updateShare keeps the current downloadable when the param is absent", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare()
Expect(share.Downloadable).To(BeTrue())
resp := doReq("updateShare", "id", share.ID, "description", "Updated")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(Equal("Updated"))
Expect(updated.Downloadable).To(BeTrue())
})
It("updateShare applies an explicit downloadable and keeps the description", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare("description", "Keep me")
resp := doReq("updateShare", "id", share.ID, "downloadable", "false")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Downloadable).To(BeFalse())
Expect(updated.Description).To(Equal("Keep me"))
})
It("updateShare clears the description when it is sent empty", func() {
share := createShare("description", "Clear me")
resp := doReq("updateShare", "id", share.ID, "description", "")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(BeEmpty())
})
})
+25 -7
View File
@@ -1,11 +1,13 @@
package subsonic
import (
"cmp"
"net/http"
"strings"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
Description: description,
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
id, err := repo.(rest.Persistable).Save(share)
@@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
// The update always writes description and downloadable, so read back the
// stored value for whichever one the client omitted.
description := p.StringPtr("description")
downloadable := p.BoolPtr("downloadable")
if description == nil || downloadable == nil {
current, err := repo.Read(id)
if err != nil {
return nil, err
}
cur := current.(*model.Share)
description = cmp.Or(description, &cur.Description)
downloadable = cmp.Or(downloadable, &cur.Downloadable)
}
share := &model.Share{
ID: id,
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ID: id,
Description: *description,
Downloadable: *downloadable,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
+16 -9
View File
@@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
return stream.IsAACCodec(p.Container)
})
player, hasPlayer := request.PlayerFrom(ctx)
// Honor the player's forced transcoding format, falling back to normal
// negotiation when the client can't play it (issue #5583).
maxBitRate := 0
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
if !clientInfo.ForceFormat(trc.TargetFormat) {
if clientInfo.ForceFormat(trc.TargetFormat) {
// DirectPlayProfile carries no bitrate, so this ceiling is the only
// thing keeping an over-bitrate source out of direct play.
maxBitRate = trc.DefaultBitRate
} else {
clientName := clientInfo.Name
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
if hasPlayer && player.Client != "" {
clientName = player.Client
}
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
@@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
}
// Apply the player's MaxBitRate as a ceiling on the client's declared
// limits (issue #5583). Both fields are capped because the client sends
// them independently here; capping only MaxAudioBitrate would let an
// independent MaxTranscodingAudioBitrate slip through computeBitrate.
if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) {
log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision",
"playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
// The player's own MaxBitRate outranks the forced-format default (issue #5583).
if hasPlayer && player.MaxBitRate > 0 {
maxBitRate = player.MaxBitRate
}
if clientInfo.CapBitrate(maxBitRate) {
log.Debug(ctx, "Applied bitrate ceiling to transcode decision",
"maxBitRate", maxBitRate, "client", clientInfo.Name)
}
// Get media file
+43 -2
View File
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
mockTD.token = "token"
})
It("forces a supported format and clears direct play", func() {
It("forces a supported format and narrows direct play to it", func() {
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
@@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() {
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty())
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
Containers: []string{"ogg"},
AudioCodecs: []string{"opus"},
Protocols: []string{"http"},
}))
})
It("falls back to negotiation when the forced format is unsupported", func() {
@@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() {
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
})
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
return r.WithContext(ctx)
}
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
})
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
})
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
})
})
})
+6
View File
@@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
].map((b) => ({ id: b, name: b.toString() }))
// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
export const TRANSCODING_BITRATE_CHOICES = [
{ id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
...BITRATE_CHOICES,
]
+3
View File
@@ -200,6 +200,9 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
},
"choices": {
"noDefaultBitRate": "None"
}
},
"playlist": {
+3 -3
View File
@@ -8,8 +8,8 @@ import {
useUnselectAll,
} from 'react-admin'
import { useSelector } from 'react-redux'
import SyncIcon from '@material-ui/icons/Sync'
import CachedIcon from '@material-ui/icons/Cached'
import { GiMagnifyingGlass } from 'react-icons/gi'
import { VscSync } from 'react-icons/vsc'
import subsonic from '../subsonic'
const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
@@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
? translate('resources.library.actions.fullScan')
: translate('resources.library.actions.quickScan')
const icon = fullScan ? <CachedIcon /> : <SyncIcon />
const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync />
return (
<Button
+2 -4
View File
@@ -598,11 +598,9 @@ const NautilineTheme = {
},
},
NDAlbumGridView: {
albumContainer: {
link: {
borderRadius: radii.md,
'& img': {
borderRadius: radii.md,
},
overflow: 'hidden',
},
albumTitle: {
fontWeight: 600,
+22
View File
@@ -12,3 +12,25 @@ describe('NDPlaylistDetails styles', () => {
},
)
})
describe('NDAlbumGridView styles', () => {
const themeEntries = Object.entries(themes)
// The hover overlay is a sibling of the image, so it keeps square corners.
it.each(themeEntries)(
'%s should not round the grid cover image on its own',
(themeName, theme) => {
const container = theme.overrides?.NDAlbumGridView?.albumContainer
expect(container?.['& img']?.borderRadius).toBeUndefined()
},
)
it.each(themeEntries)(
'%s should clip the grid cover link when it is rounded',
(themeName, theme) => {
const link = theme.overrides?.NDAlbumGridView?.link
if (!link?.borderRadius) return
expect(link.overflow).toBe('hidden')
},
)
})
+5 -1
View File
@@ -59,7 +59,11 @@ const useCurrentTheme = () => {
return useMemo(
() => ({
...theme,
props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
props: {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
},
}),
[theme],
)
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = () => {
const translate = useTranslate()
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput
source="defaultBitRate"
choices={BITRATE_CHOICES}
choices={TRANSCODING_BITRATE_CHOICES}
defaultValue={192}
/>
<TextInput
+5 -2
View File
@@ -9,7 +9,7 @@ import {
} from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
const translate = useTranslate()
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} />
<SelectInput
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextInput source="command" fullWidth validate={[required()]} />
</SimpleForm>
</Edit>
+13 -3
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Datagrid, TextField } from 'react-admin'
import { Datagrid, SelectField, TextField } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import config from '../config'
const TranscodingList = (props) => {
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
tertiaryText={(r) => r.defaultBitRate}
tertiaryText={(r) => (
<SelectField
record={r}
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
)}
/>
) : (
<Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</Datagrid>
)}
+6 -2
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Show, SimpleShowLayout, TextField } from 'react-admin'
import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
return <Title subTitle={`Transcoding ${record ? record.name : ''}`} />
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
<SimpleShowLayout>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</SimpleShowLayout>
</Show>