From 7ab8f319d7e4154c1729747860a04de83d25a923 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:09:27 +0200
Subject: [PATCH 01/13] docs: RFC-021 proposes the library lifecycle,
exclusions, links, missing tracks and watching
Signed-off-by: InstaZDLL
---
.../RFC-021-library-lifecycle-and-scan.md | 341 ++++++++++++++++++
1 file changed, 341 insertions(+)
create mode 100644 docs/rfcs/RFC-021-library-lifecycle-and-scan.md
diff --git a/docs/rfcs/RFC-021-library-lifecycle-and-scan.md b/docs/rfcs/RFC-021-library-lifecycle-and-scan.md
new file mode 100644
index 00000000..685dd99d
--- /dev/null
+++ b/docs/rfcs/RFC-021-library-lifecycle-and-scan.md
@@ -0,0 +1,341 @@
+# RFC-021 — Libraries and scanning: exclusions, links, missing tracks, the library lifecycle and watching
+
+- **Status**: Proposed
+- **Implemented by**: in progress (this RFC and its implementation, in one
+ pull request). The *Status* field above never flips in this project; this
+ line is the one that says what is running.
+- **Date**: 2026-10-25
+- **Authors**: the WaveFlow project
+- **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md) (the scanner, track
+ identity, tenancy), [RFC-018](RFC-018-incremental-scan.md) (the incremental
+ scan and its tolerant walk, whose guarantees all hold here)
+
+## Problem
+
+A comparative audit of 2026-10-24 found five gaps around libraries and how they
+are scanned. Each was confirmed in the code.
+
+1. **The walk excludes nothing.** `discover_audio` (`src/scanner.rs`) indexes
+ everything with an audio extension, so Synology's `@eaDir` thumbnails, the
+ `._` AppleDouble files a Mac leaves on a share, `.Trash-1000`,
+ `$RECYCLE.BIN` and every hidden folder are read as music. There is no way
+ for an operator to say "not this folder" (Navidrome has `.ndignore`).
+2. **Symbolic links are dropped in silence.** The walk does not follow links
+ and does not say it skipped one, so a library built of links appears empty
+ and nothing explains why.
+3. **A track whose file is gone is kept for ever and shown nowhere.** It goes
+ to `is_available = 0` and stays, holding its favourites, playlist entries
+ and plays, with no page listing it and no way to remove it.
+4. **A library can be added and never changed.** Nothing renames one, moves
+ its root or deletes it: `src/` holds no `UPDATE library` touching the name
+ or the path and no `DELETE FROM library`, and the CLI has only `add`.
+5. **Changes are seen at the next interval only.** The only trigger besides a
+ manual one is the periodic scan, six hours by default
+ (`WAVEFLOW_SCAN_INTERVAL_SECS`).
+
+## Decision 1 — what the walk leaves out
+
+**Built-in exclusions**, applied to every entry below the library root (never
+to the root itself or to the folders above it), by name:
+
+- any name beginning with `.` — hidden files and folders, which covers
+ `.Trash-*`, `.AppleDouble`, `._*` and `.DS_Store` — **except
+ `.waveflow-managed` directly under the root**, the folder where received files
+ land (RFC-008), which the scan must keep finding;
+- `@eaDir`, `#recycle`, `#snapshot` (Synology), `$RECYCLE.BIN`,
+ `System Volume Information` (Windows) and `lost+found`, compared without
+ regard to case.
+
+Built-in exclusions cannot be overridden: a dot-folder of music is rare, and a
+rule that a file can switch off is a rule every library has to check.
+
+**An ignore file**, `.waveflowignore`, in any folder of the library. Its syntax
+is `.gitignore`'s, read by the `ignore` crate's matcher, so the rules are the
+ones every operator already knows: one pattern per line, `#` comments, a
+leading `/` anchors to the file's folder, a trailing `/` matches folders only,
+`**` crosses folders, and `!` re-includes. A file applies to its folder and
+everything below it, and a deeper file's rules win over a shallower one's, as in
+git. A folder that is excluded is not entered, so `!` cannot re-include
+something under it, as in git.
+
+An **empty** `.waveflowignore` (nothing but white space) excludes the folder it
+sits in and everything below it, as an empty `.ndignore` does in Navidrome.
+That rule does not apply at the library root, where an empty file excludes
+nothing: one stray empty file must not mark a whole library missing.
+
+The rules are matched against the path the walk sees, so a link (decision 2)
+is excluded or not by its own name, whatever its target is called.
+
+**An ignore file that cannot be used**, because it cannot be read, is larger
+than 64 KiB, is not a regular file, or holds a pattern the matcher rejects,
+makes its folder count as **unreadable**, exactly as RFC-018 decision 3 defines
+the word: it is not entered, it is reported in the scan's errors with its path
+relative to the library, and the tracks under it are left as they are. The
+scan cannot know what the operator meant to exclude, so it neither indexes the
+folder nor marks it missing. At the library root the same failure **fails the
+scan**, since then nothing at all can be told apart; that is the RFC-018 rule
+for a root that cannot be opened.
+
+## Decision 2 — an excluded file is a file the walk did not find
+
+**An excluded file counts as not discovered**, and that is the whole of its
+treatment. It is *not* one of the files RFC-018 decision 3 protects, because
+the walk did look: it saw the file and was told to leave it out. So:
+
+- a track already in the catalogue whose file is now excluded **becomes
+ unavailable at the end of the scan, exactly like a deleted one**, with the
+ `delete` event the library feed already sends for that;
+- it may be the source of a relocation like a deleted one: a file elsewhere
+ with the same content (or, failing that, the same relocation hint) takes its
+ identity, favourites and playlist entries included;
+- removing the rule makes it available again on the next scan, under the same
+ identifier, as a file that comes back does today.
+
+Only an unreadable folder (decision 1) and an unreadable entry (RFC-018) keep
+their tracks as they are. The scan job counts what was left out in a new
+column, `excluded`: one per excluded entry, a folder counting once whatever it
+holds.
+
+## Decision 3 — symbolic links, followed inside the library only
+
+**Following links is a per-library setting, `follow_links`, off by default**,
+set by an administrator (API, CLI or the web client). Per library rather than
+global because a link farm is a property of one tree, and because a server
+holding several libraries should not change what all of them read for the sake
+of one.
+
+**With it off**, the walk does not follow any link, as today, but it no longer
+does so silently: each link it passes is **counted** in the scan job's new
+`skipped_links` column and **logged** (at `info`, by its path relative to the
+library, the first twenty of a scan one by one and the rest as a count).
+
+**With it on**, a link is followed only when its canonical target — every link
+on the way resolved — **stays inside the library root**. Anything else is
+skipped, counted and logged in the same way:
+
+- a link whose target lies outside the root;
+- a broken link;
+- a loop (a link to one of its own ancestors), which `walkdir` detects.
+
+The rule exists because a link is a way for anyone who can write into the
+library folder to make the server read and serve any file the server can read.
+Confinement keeps the library the boundary it is everywhere else in this
+server (streaming already refuses any path that leaves the root).
+
+**A file reached through a link is indexed under its canonical path**, the path
+of its target relative to the root, not under the link's name. Three things
+follow, all deliberate:
+
+- **one file is one track** however many links name it, and a file reached both
+ directly and through a link is not indexed twice;
+- **the stored path never contains a link**, so the streaming path check
+ (`secure_track_path`, which refuses any link component) is unchanged;
+- **renaming a link moves nothing**: identity follows the file.
+
+The cost is that the disk tree (RFC-012) shows the targets' layout, not the
+links'. The use this serves is a link farm whose files live inside the library,
+for example in a store folder the walk does not enter directly:
+
+```text
+/music ← library root, follow_links on
+/music/.waveflowignore ← "/store/"
+/music/store/… ← the files, excluded from the direct walk
+/music/Artist/Album/01.flac → ../../store/ab/cd.flac
+```
+
+A link farm whose files live **outside** the root (a beets `link: yes` import
+into a separate folder, say) is not followed. The operator either makes the
+root a common ancestor of both, as above, or adds the files' folder as a
+library of its own.
+
+## Decision 4 — missing tracks: a page, a purge, and an optional retention
+
+**`unavailable_since`**, a new nullable column on `track`, records when a track
+stopped being available. It is set when a scan marks a track missing (excluded
+or deleted), cleared when one comes back, and filled from `updated_at` for the
+tracks already unavailable when the migration runs.
+
+**A view**, for the library's owners and managers and for administrators:
+`GET /api/v2/libraries/{id}/unavailable-tracks` lists the unavailable tracks,
+oldest first, each with its path, title, artist, album, when it went missing,
+and **what purging it would cost**: how many playlist entries, favourites and
+recorded plays hold it. The web client shows it on its own page, "Missing
+files", beside "Needs attention".
+
+**A purge**, explicit: `POST /api/v2/libraries/{id}/unavailable-tracks/purge`
+with either `{"track_ids": […]}` or `{"all": true}`, and never neither. Only
+tracks that are **still** unavailable when the purge runs are removed: one that
+came back between the listing and the click is kept. It is also offered by the
+CLI (`library unavailable`, `library purge`).
+
+**What a purge loses**, stated here because none of it comes back:
+
+- the track rows, and everything that cascades from them: playlist entries,
+ favourites and ratings of the track, play history, queue entries, shares'
+ entries, bookmarks, lyrics (embedded, sidecar and uploaded), corrections
+ (`track_override`), credits, genre links, folder placements, canvas links and
+ quality measurements;
+- the albums and artists that hold no track any more, with their favourites
+ and ratings; a pass of the scan's own consolidation runs in the same
+ transaction, so the catalogue is never left with an empty album;
+- nothing on disk but what the server made: audio files are never touched,
+ covers nothing references any more are left to the existing artwork sweep,
+ and a canvas (RFC-009) whose last link went with these tracks is erased after
+ the commit, exactly as removing that canvas would erase it.
+
+A file that comes back after its track was purged is a new track with a new
+identifier.
+
+The purge takes the writer gate and runs in **one transaction**: tracks,
+favourites, search index rows and the consolidation land or fail together. It
+**refuses with 409 while the library is being scanned** (it takes the
+library's scan lock without waiting), because a running scan may be about to
+relocate one of those tracks onto a file it just found. It is audited.
+
+The library feed is not written again: it already said `delete` for each of
+these tracks when they went missing. The user sync journal is not written
+either; a device holding a favourite or a playlist entry of a track that no
+longer exists drops it with the track (see "What is still open").
+
+**Retention, off by default**: `WAVEFLOW_UNAVAILABLE_RETENTION_DAYS` (unset or
+`0`: never). When set, a background pass once an hour purges, through the same
+code, the tracks unavailable for longer than that, in every library that is not
+being scanned. Off by default because the purge is not reversible and a share
+that stays unmounted for a week is not a deletion.
+
+## Decision 5 — the library lifecycle
+
+Three operations, each in the native API, the CLI and the web client, all
+through one implementation in `src/services/`.
+
+| Operation | Who | API |
+| --- | --- | --- |
+| Rename | the library's owner, or an administrator | `PATCH /api/v2/libraries/{id}` `{"name": …}` |
+| Change the path | an administrator | `PATCH /api/v2/libraries/{id}` `{"path": …}` |
+| Follow links on or off | an administrator | `PATCH /api/v2/libraries/{id}` `{"follow_links": …}` |
+| Delete | an administrator | `DELETE /api/v2/libraries/{id}` `{"confirm": ""}` |
+
+The path and the links setting are an administrator's for the reason creating a
+library is: they decide which part of the server's file system the server
+reads. A `PATCH` naming either needs an administrative credential, not only an
+administrator's account; a rename needs a credential that may write.
+
+**Changing the path** validates the new one as creating a library does (an
+existing directory, not itself a link, stored canonical, not another library's
+root), then queues a scan with the new trigger `library_moved`. Tracks are
+matched by their path relative to the root, so **a root moved with its tree
+intact keeps every identifier**, favourite and playlist entry. What the new
+root does not hold is marked missing, and relocation by content still applies
+to what moved within it. Changing `follow_links` queues a scan too.
+
+**Deleting** removes the library from the catalogue and nothing from the disk.
+Its cascade, all in one transaction under the writer gate:
+
+- every track, with everything decision 4 lists for a purge — for **every
+ member**, not only the one deleting;
+- every album, artist and genre of the library, their favourites and ratings,
+ and their search index rows;
+- the memberships, the scan history and its errors, the library feed and its
+ acknowledgements, folders (RFC-012), credit decisions, upload sessions;
+- audited as `library.deleted`.
+
+Received files stay where they are: the server wrote them, but they are the
+library's files now. The staging files of upload sessions still open are
+removed, after the commit, and so are the canvases no other library still links
+to; covers are left to the artwork sweep. A client polling the library's feed
+gets 404 from then on, which is its signal to drop the library.
+
+**Confirmation is in the request's shape.** The body must carry `confirm`, equal
+to the library's current name; anything else is 422 and deletes nothing. The
+web client asks for the name to be typed; the CLI takes `--confirm `.
+
+All three refuse with 409 while the library is being scanned, for the reason
+the purge does. Every refusal of an operation the caller may not perform on a
+library they cannot see is a 404, as everywhere else.
+
+## Decision 6 — watching the file system
+
+**A watcher**, on the `notify` crate, observes every library root recursively
+and triggers an **incremental scan of the affected library once its changes
+have settled**: no event for `WAVEFLOW_WATCH_DELAY_SECS` (10 by default). A
+stream of changes that never settles, a long copy for instance, still triggers
+a scan once thirty delays have passed since its first event, and the next quiet
+moment triggers another. The debouncing is the server's own, per library, on
+top of the raw events: one crate rather than two, and a rule this RFC can state.
+
+- **The periodic scan stays**, as the safety net for everything a watcher can
+ miss.
+- **`WAVEFLOW_WATCH`** (`true` by default) turns it off.
+- **It never starts a scan while one runs for that library.** A library whose
+ changes settle during a scan stays pending and is scanned once that one ends,
+ so a change the running scan walked past is not lost. Scans keep everything
+ else they had: the per-library lock, the writer gate, concurrency between
+ libraries and never between writers.
+- **Events that cannot be changes to the music are ignored**: reads and access
+ times (the scanner reads files, and would otherwise trigger itself for ever),
+ and anything under `.waveflow-managed`, which an upload writes and applies
+ itself.
+- **A queue overflow** (the kernel dropping events) marks every library pending.
+- **The watched set follows the libraries**: it is refreshed when a library is
+ added, moved or deleted through the server, and every five minutes for the
+ changes the CLI makes from another process.
+
+**A watch that cannot be established is not an error.** If the watcher cannot
+start, or a root cannot be watched (a mount that does not support it, the
+inotify limit `fs.inotify.max_user_watches` reached), that is logged once with
+the library's identifier and the library is left to the periodic scan. Network
+mounts are the known case: NFS and SMB report changes made by this machine and
+usually not those made by another, so a library on a share should not count on
+the watcher. `docs/operations.md` says so.
+
+## Decision 7 — what the scan job says
+
+The scan job gains two counters, `excluded` and `skipped_links` (decisions 2
+and 3), in the table, `GET /api/v2/scans/{id}` and both frames of the progress
+stream, and two triggers, `watch` and `library_moved`. SQLite cannot alter the
+`CHECK` on `scan_job.trigger`, so the table is rebuilt; migrations run with
+foreign keys off, so the rows that reference it (`scan_error`,
+`track.last_seen_scan_id`) are carried and checked by the boot's
+`PRAGMA foreign_key_check`.
+
+## What does not change
+
+- Track identity, relocation, the incremental pre-check, ordered streaming and
+ RFC-018's rules for what the walk could not see.
+- Audio files are read-only; nothing here writes to a library.
+- Creating a library, its membership rules, and the Subsonic façade, whose
+ frozen contract has no library management.
+
+## The order of the work
+
+1. **One PR**, holding this RFC and:
+ - the migration (`library.follow_links`, `track.unavailable_since`, the
+ rebuilt `scan_job`);
+ - the exclusions, `.waveflowignore` and link handling in the walk;
+ - the missing-tracks listing and purge, and the retention pass;
+ - rename, path change, links setting and deletion, in the services, the
+ API, the CLI and the web client;
+ - the watcher and its two settings;
+ - tests for each rule, among them: an excluded track becomes unavailable
+ and an unreadable ignore file keeps its tracks; a link outside the root is
+ skipped and counted; a purge removes only what is still unavailable and
+ leaves no empty album; a moved root keeps identities; a deletion leaves no
+ row behind; a watched change triggers a scan, and none starts while one
+ runs;
+ - `docs/operations.md`.
+
+## What is still open
+
+- **The user sync journal on a purge or a deletion.** A favourite or a
+ playlist entry removed by the cascade is not written to the journal of the
+ account that held it, so a device keeps its copy until that playlist or
+ favourite next changes. The library feed's `delete` for the track is the
+ signal a client has today.
+- **Following links outside the root**, to an allowlist of further roots, for
+ link farms that live elsewhere.
+- **Undoing a purge**, for instance by keeping purged rows for a grace period.
+- **A per-library watcher switch**, for a library on a share where watching
+ costs and finds nothing.
+- **Folder-level skipping** (RFC-018) and watching would combine well: a
+ watcher that names the changed folder could let a scan walk only that
+ folder. Not done; every watched change is a full incremental walk.
From 8b60695e26a3447e786a9cb5a5a20ad5cd6daef6 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:09:27 +0200
Subject: [PATCH 02/13] =?UTF-8?q?feat:=20libraries=20and=20scanning=20?=
=?UTF-8?q?=E2=80=94=20exclusions,=20links,=20missing=20tracks,=20lifecycl?=
=?UTF-8?q?e=20and=20watching=20(RFC-021)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: InstaZDLL
---
.env.example | 8 +
Cargo.lock | 208 +++-
Cargo.toml | 9 +
docs/api-v2-guide.md | 21 +-
docs/operations.md | 120 +++
.../20261026010000_library_lifecycle.sql | 60 ++
src/api/libraries.rs | 120 +++
src/api/mod.rs | 16 +
src/catalog.rs | 78 +-
src/cli.rs | 285 ++++++
src/config.rs | 39 +
src/lib.rs | 15 +
src/main.rs | 10 +
src/scanner.rs | 530 ++++++++++-
src/services/canvas.rs | 2 +-
src/services/library_lifecycle.rs | 848 +++++++++++++++++
src/services/mod.rs | 9 +
src/watcher.rs | 353 +++++++
tests/cli.rs | 151 +++
tests/migrations.rs | 68 +-
tests/native_api.rs | 263 +++++
tests/scanner.rs | 898 +++++++++++++++++-
webapp/src/api/index.ts | 8 +
webapp/src/api/libraries.ts | 82 ++
webapp/src/api/queries.ts | 31 +
webapp/src/main.tsx | 17 +
webapp/src/pages/admin-page.tsx | 203 ++++
webapp/src/pages/missing-page.tsx | 222 +++++
webapp/src/state/i18n.tsx | 91 ++
webapp/src/styles/styles.css | 54 ++
30 files changed, 4760 insertions(+), 59 deletions(-)
create mode 100644 migrations-v2/20261026010000_library_lifecycle.sql
create mode 100644 src/services/library_lifecycle.rs
create mode 100644 src/watcher.rs
create mode 100644 webapp/src/pages/missing-page.tsx
diff --git a/.env.example b/.env.example
index 894b07e6..06ccfc71 100644
--- a/.env.example
+++ b/.env.example
@@ -52,6 +52,14 @@ WAVEFLOW_STREAM_TICKET_TTL_SECS=3600
# remain available. Extraction is parallel, SQLite writes stay serialized.
WAVEFLOW_SCAN_INTERVAL_SECS=21600
WAVEFLOW_SCAN_PARALLELISM=4
+# Watch library folders and scan a library once its changes have been quiet
+# for the delay. Unreliable on network mounts; the interval above stays as the
+# safety net (RFC-021).
+WAVEFLOW_WATCH=true
+WAVEFLOW_WATCH_DELAY_SECS=10
+# Purge tracks whose file has been missing for longer than this many days.
+# 0 never purges on its own; a purge is not reversible.
+WAVEFLOW_UNAVAILABLE_RETENTION_DAYS=0
# FFmpeg and ffprobe are mandatory from M2 onward. Override these when the
# executables are not discoverable through PATH.
diff --git a/Cargo.lock b/Cargo.lock
index 5dc589f3..2e784871 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -610,6 +610,25 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -913,6 +932,15 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -1394,6 +1422,22 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "ignore"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
[[package]]
name = "image"
version = "0.25.10"
@@ -1441,6 +1485,26 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "inout"
version = "0.2.2"
@@ -1479,6 +1543,26 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -1662,6 +1746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1682,6 +1767,33 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2975,7 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3654,10 +3766,12 @@ dependencies = [
"futures-util",
"hex",
"http-body-util",
+ "ignore",
"ipnet",
"libc",
"lofty",
"md-5",
+ "notify",
"percent-encoding",
"rand 0.10.3",
"realfft",
@@ -3803,7 +3917,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
]
[[package]]
@@ -3821,14 +3944,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
]
[[package]]
@@ -3837,48 +3977,96 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
diff --git a/Cargo.toml b/Cargo.toml
index 7c900ba6..9deb5f2a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,11 @@ encoding_rs = "0.8.42"
futures-util = "0.3"
hex = "0.4"
ipnet = "2"
+# `.waveflowignore` (RFC-021 decision 1): gitignore rules through the matcher
+# ripgrep uses. Only its `gitignore` module is used, beside the `walkdir` walk
+# the scanner already has; `globset` and `walkdir` it builds on are in the
+# tree already.
+ignore = "0.4"
lofty = "0.25"
# TTML lyrics uploads (RFC-012 decision 6). Already in the lock file through
# the artwork renderer; named here because the server now parses XML itself.
@@ -46,6 +51,10 @@ lofty = "0.25"
roxmltree = "0.21"
libc = "0.2"
md-5 = "0.11"
+# The library watcher (RFC-021 decision 6). Default features only, which is the
+# FSEvents backend on macOS and nothing else: inotify on Linux and
+# ReadDirectoryChangesW on Windows need no feature.
+notify = "8"
# Already in the tree through `url`; declared because `playlist_file` reads
# percent escapes out of playlist entries and a transitive dependency is not a
# promise.
diff --git a/docs/api-v2-guide.md b/docs/api-v2-guide.md
index d0467e99..a9448480 100644
--- a/docs/api-v2-guide.md
+++ b/docs/api-v2-guide.md
@@ -1135,6 +1135,7 @@ Admin Bearer tokens can manage:
| Users | `GET/POST /api/v2/admin/users`, `PATCH/DELETE /api/v2/admin/users/{username}` |
| Subsonic credential | `PUT/DELETE /api/v2/admin/users/{username}/subsonic-credential` |
| Libraries | `GET/POST /api/v2/libraries` |
+| A library's name, folder and links setting, and its deletion | `GET /api/v2/libraries/{library_id}/settings`, `PATCH/DELETE /api/v2/libraries/{library_id}` |
| Membership | `PUT/DELETE /api/v2/libraries/{library_id}/members/{user_id}` |
| Scans | `POST /api/v2/libraries/{library_id}/scans`, `GET /api/v2/scans/{scan_id}`, `GET /api/v2/scans/{scan_id}/events` |
| API tokens | `GET/POST /api/v2/admin/users/{username}/tokens`, `DELETE /api/v2/admin/users/{username}/tokens/{token_id}` |
@@ -1149,6 +1150,24 @@ takes the instance's write lock, so `POST /api/v2/libraries/{library_id}/scans`
is reserved to the `owner` and `manager` roles; a `listener` gets `404`, the
same answer as for a library that does not exist. Reading the catalogue is
unaffected.
+
+A library's lifecycle ([RFC-021](rfcs/RFC-021-library-lifecycle-and-scan.md)
+decision 5) splits the same way. `PATCH /api/v2/libraries/{library_id}` with a
+`name` is open to the library's owner; with a `path` or `follow_links` it needs
+an administrator on an administrative credential, and it queues a scan whose id
+it answers. `DELETE` is an administrator's, and its body must carry
+`{"confirm": ""}` or nothing is deleted (`422`).
+Every one of them answers `409` while the library is being scanned.
+
+The tracks a library's scans can no longer find are listed, oldest first and
+with what purging each would cost, by
+`GET /api/v2/libraries/{library_id}/unavailable-tracks`, and purged by
+`POST /api/v2/libraries/{library_id}/unavailable-tracks/purge` with
+`{"track_ids": […]}` or `{"all": true}`. Both are for the library's owners,
+managers and administrators; a purge is not reversible (RFC-021 decision 4).
+
+A scan job also says what its walk left out: `excluded` (built-in exclusions
+and `.waveflowignore`) and `skipped_links` (symbolic links not followed).
| FFmpeg status and transcode headroom | `GET /api/v2/transcode/status` |
| Library change feed | `GET /api/v2/libraries/{library_id}/events` |
@@ -1166,7 +1185,7 @@ tell the two apart.
event: snapshot
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":0,
"added":0,"updated":0,"moved":0,"skipped":0,"unavailable":0,"errors":0,
- "current_path":null,"message":null}
+ "excluded":0,"skipped_links":0,"current_path":null,"message":null}
event: progress
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":61, …}
diff --git a/docs/operations.md b/docs/operations.md
index 5c6bc56a..fb7fb43e 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -287,6 +287,126 @@ the scan goes on. The tracks under it are left as they are, not marked missing,
so a folder that is briefly unreadable does not make its albums vanish and come
back. Only the library root itself failing to open fails the scan.
+[RFC-021](rfcs/RFC-021-library-lifecycle-and-scan.md) carries the reasoning
+for everything below.
+
+### What a scan leaves out
+
+Some folders are never scanned, whatever else says: anything whose name begins
+with a dot (hidden folders and files, `.Trash-*`, the `._` files a Mac leaves
+on a share), `@eaDir`, `#recycle` and `#snapshot` (Synology), `$RECYCLE.BIN`
+and `System Volume Information` (Windows), and `lost+found`. The one exception
+is `.waveflow-managed` at the library root, where received files land.
+
+To leave out more, put a **`.waveflowignore`** file in any folder. Its syntax is
+`.gitignore`'s and applies to that folder and below:
+
+```text
+# no live recordings, no demos anywhere, no AAC except this one
+/Live/
+**/demos/
+*.m4a
+!Keep me.m4a
+```
+
+As in git, a folder that is left out is not entered, so a `!` cannot bring back
+something inside it.
+
+An **empty** `.waveflowignore` leaves out the whole folder it sits in, as an
+empty `.ndignore` does in Navidrome — except at the library root, where it
+leaves out nothing. A file the scan cannot use (unreadable, over 64 KiB, or
+holding a pattern it rejects) makes its folder count as unreadable: reported in
+the scan's errors and left as it is. At the library root, it fails the scan.
+
+**What you leave out is treated as gone.** A track whose file becomes excluded
+turns unavailable at the end of the scan, exactly as if the file had been
+deleted, and comes back under the same identity when the rule is removed. The
+scan's `excluded` counter says how many entries were left out.
+
+### Symbolic links
+
+By default a scan does not follow symbolic links. It no longer skips them in
+silence: each one is counted in the scan's `skipped_links` and logged by its
+path in the library. To follow them, per library:
+
+```bash
+waveflow-server library set-links --actor admin --library-id --follow
+```
+
+A followed link must lead **inside the library**: one whose target is outside
+the root, broken, or a loop back to a folder above it is skipped and counted. A
+file reached through links is one track, under its own path, however many links
+name it. A link farm whose files live in the same tree works best with the
+files' folder excluded from the direct walk:
+
+```text
+/music/.waveflowignore /store/
+/music/store/… the files
+/music/Artist/Album/… links into ../../store/
+```
+
+A link farm whose files live outside the root is not followed; make the root a
+common ancestor, or add the files' folder as a library of its own.
+
+### Missing files
+
+A track whose file is gone stays in the catalogue, unavailable, with its
+favourites, playlist entries and plays, so a share that comes back brings it
+back whole. Owners and managers see them on the **Missing files** page, which
+says for each what a purge would take; the CLI lists them too:
+
+```bash
+waveflow-server library unavailable --actor admin --library-id
+waveflow-server library purge --actor admin --library-id --all
+waveflow-server library purge --actor admin --library-id --track-id
+```
+
+**A purge is not reversible.** It removes the tracks and everything that hangs
+off them for every member — playlist entries, favourites, ratings, plays, queue
+entries, corrections, lyrics — and the albums and artists left with nothing. A
+file that comes back afterwards is a new track. Only tracks still missing when
+the purge runs are removed, and a purge waits for no scan: it answers that the
+library is busy instead.
+
+To purge on a schedule, set **`WAVEFLOW_UNAVAILABLE_RETENTION_DAYS`**: once an
+hour, tracks missing for longer than that are purged. It is off by default,
+because a share that stays unmounted for a week is not a deletion.
+
+### Renaming, moving and deleting a library
+
+From the administration page, or:
+
+```bash
+waveflow-server library rename --actor admin --library-id --name "Lossless"
+waveflow-server library set-path --actor admin --library-id --path /mnt/music
+waveflow-server library delete --actor admin --library-id --confirm "Lossless"
+```
+
+**Moving** points the library at a new folder and scans it. Tracks are matched
+by their path inside the library, so a tree moved intact keeps every identity,
+favourite and playlist entry. The owner of a library may rename it; moving it,
+its links setting and deleting it are an administrator's.
+
+**Deleting** removes the library from the catalogue for every member — its
+tracks, albums and artists with everything that hangs off them, its scans,
+folders and memberships — and nothing from the disk. `--confirm` must be the
+library's current name. None of these runs while the library is being scanned.
+
+### Watching for changes
+
+The server watches every library folder and scans a library once its changes
+have been quiet for `WAVEFLOW_WATCH_DELAY_SECS` (10 by default); a copy that
+never pauses is still scanned every thirty delays. The periodic scan stays as
+the safety net. Set `WAVEFLOW_WATCH=false` to turn watching off.
+
+**Do not count on it for network mounts.** NFS and SMB report the changes this
+machine makes and usually not those another machine makes, so a library on a
+share is found up to date at the next periodic scan. A folder that cannot be
+watched is logged once and left to the periodic scan. On Linux, a large
+library can exhaust the inotify watch limit; raise it with
+`sysctl fs.inotify.max_user_watches=524288` (and in `/etc/sysctl.d/` to keep
+it), or the library falls back to the periodic scan.
+
## Probes
| Path | Answers |
diff --git a/migrations-v2/20261026010000_library_lifecycle.sql b/migrations-v2/20261026010000_library_lifecycle.sql
new file mode 100644
index 00000000..cfae9827
--- /dev/null
+++ b/migrations-v2/20261026010000_library_lifecycle.sql
@@ -0,0 +1,60 @@
+-- Libraries and scanning (RFC-021).
+--
+-- `library.follow_links`: whether the walk follows symbolic links whose target
+-- stays inside the library root (decision 3). Off for every existing library:
+-- upgrading must not change what a library reads.
+ALTER TABLE library ADD COLUMN follow_links INTEGER NOT NULL DEFAULT 0
+ CHECK (follow_links IN (0, 1));
+
+-- `track.unavailable_since`: when a track stopped being available, so the
+-- missing-tracks page can say since when and the optional retention can purge
+-- by age (decision 4). NULL for every available track. The tracks already
+-- unavailable take the time their row was last written, which is when the scan
+-- marked them missing unless something touched them since.
+ALTER TABLE track ADD COLUMN unavailable_since INTEGER;
+UPDATE track SET unavailable_since = updated_at WHERE is_available = 0;
+
+-- `scan_job` gains two counters and two triggers (decision 7). SQLite cannot
+-- alter a CHECK, so the table is rebuilt. Migrations run with foreign keys
+-- off, so `scan_error.scan_id` and `track.last_seen_scan_id`, which name this
+-- table, are neither cascaded nor nulled by the drop; they resolve to the
+-- rebuilt table by name, and the boot's foreign_key_check answers for it.
+CREATE TABLE scan_job_rebuilt (
+ id TEXT PRIMARY KEY NOT NULL,
+ library_id TEXT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
+ requested_by TEXT REFERENCES account(id) ON DELETE SET NULL,
+ trigger TEXT NOT NULL CHECK (
+ trigger IN ('manual', 'boot', 'scheduled', 'library_added', 'watch', 'library_moved')
+ ),
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'failed')),
+ total_files INTEGER NOT NULL DEFAULT 0,
+ processed_files INTEGER NOT NULL DEFAULT 0,
+ added INTEGER NOT NULL DEFAULT 0,
+ updated INTEGER NOT NULL DEFAULT 0,
+ moved INTEGER NOT NULL DEFAULT 0,
+ skipped INTEGER NOT NULL DEFAULT 0,
+ unavailable INTEGER NOT NULL DEFAULT 0,
+ errors INTEGER NOT NULL DEFAULT 0,
+ current_path TEXT,
+ message TEXT,
+ created_at INTEGER NOT NULL,
+ started_at INTEGER,
+ completed_at INTEGER,
+ full_scan INTEGER NOT NULL DEFAULT 0 CHECK (full_scan IN (0, 1)),
+ excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded >= 0),
+ skipped_links INTEGER NOT NULL DEFAULT 0 CHECK (skipped_links >= 0)
+) STRICT;
+
+INSERT INTO scan_job_rebuilt
+ (id, library_id, requested_by, trigger, status, total_files, processed_files,
+ added, updated, moved, skipped, unavailable, errors, current_path, message,
+ created_at, started_at, completed_at, full_scan)
+SELECT id, library_id, requested_by, trigger, status, total_files, processed_files,
+ added, updated, moved, skipped, unavailable, errors, current_path, message,
+ created_at, started_at, completed_at, full_scan
+FROM scan_job;
+
+DROP TABLE scan_job;
+ALTER TABLE scan_job_rebuilt RENAME TO scan_job;
+
+CREATE INDEX scan_job_library_idx ON scan_job(library_id, created_at DESC);
diff --git a/src/api/libraries.rs b/src/api/libraries.rs
index c753bc45..b361532e 100644
--- a/src/api/libraries.rs
+++ b/src/api/libraries.rs
@@ -84,6 +84,7 @@ pub async fn create_library(
)
.await
.map_err(db_error)?;
+ state.scanner.libraries_changed();
let scan_id = state
.scanner
.trigger(
@@ -384,3 +385,122 @@ pub async fn library_tree(
.map(Json)
.map_err(service_error)
}
+
+/// What deleting a library must carry: its current name, exactly. A body that
+/// lacks it, or names anything else, deletes nothing and answers `422`.
+#[derive(Debug, Deserialize, ToSchema)]
+pub struct DeleteLibraryRequest {
+ #[serde(default)]
+ pub confirm: String,
+}
+
+/// Renames a library, moves its root or changes whether its walk follows
+/// links (RFC-021 decision 5). A name is the owner's or an administrator's to
+/// change; the path and `follow_links` are an administrator's, on a credential
+/// that carries administrative authority. A new root or links setting queues a
+/// scan, whose id is answered. `409` while the library is being scanned, or
+/// when another library already has that root.
+#[utoipa::path(patch, path = "/api/v2/libraries/{library_id}", tag = "administration", params(("library_id" = Uuid, Path)), request_body = crate::services::LibraryUpdate, responses((status = 200, body = crate::services::LibraryUpdated), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
+pub async fn update_library(
+ State(state): State,
+ Path(library_id): Path,
+ headers: HeaderMap,
+ Json(request): Json,
+) -> Result, ApiError> {
+ let access = if request.is_administrative() {
+ Access::Admin
+ } else {
+ Access::Write
+ };
+ let actor = authenticated(&state, &headers, access).await?;
+ state
+ .services
+ .update_library(actor.id, library_id, request)
+ .await
+ .map(Json)
+ .map_err(service_error)
+}
+
+/// Deletes a library from the catalogue, and nothing from the disk (RFC-021
+/// decision 5): every track, album and artist, with everything that cascades
+/// from them for every member. The body must carry `confirm`, equal to the
+/// library's current name. `409` while the library is being scanned.
+#[utoipa::path(delete, path = "/api/v2/libraries/{library_id}", tag = "administration", params(("library_id" = Uuid, Path)), request_body = DeleteLibraryRequest, responses((status = 200, body = crate::services::LibraryDeleted), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
+pub async fn delete_library(
+ State(state): State,
+ Path(library_id): Path,
+ headers: HeaderMap,
+ Json(request): Json,
+) -> Result, ApiError> {
+ let actor = authenticated(&state, &headers, Access::Admin).await?;
+ state
+ .services
+ .delete_library(actor.id, library_id, &request.confirm)
+ .await
+ .map(Json)
+ .map_err(service_error)
+}
+
+/// A library as an administrator manages it, its root included.
+#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/settings", tag = "administration", params(("library_id" = Uuid, Path)), responses((status = 200, body = crate::services::LibrarySettings), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
+pub async fn library_settings(
+ State(state): State,
+ Path(library_id): Path,
+ headers: HeaderMap,
+) -> Result, ApiError> {
+ let actor = authenticated(&state, &headers, Access::Admin).await?;
+ state
+ .services
+ .library_settings(actor.id, library_id)
+ .await
+ .map(Json)
+ .map_err(service_error)
+}
+
+#[derive(Debug, Deserialize)]
+pub struct UnavailableTracksQuery {
+ pub offset: Option,
+ pub limit: Option,
+}
+
+/// The tracks a library's scans can no longer find, oldest first, with what
+/// purging each would cost (RFC-021 decision 4). For the library's owners and
+/// managers, and administrators; anyone else gets the `404` a library they are
+/// not in would.
+#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/unavailable-tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = crate::services::UnavailableTracks), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
+pub async fn unavailable_tracks(
+ State(state): State,
+ Path(library_id): Path,
+ Query(query): Query,
+ headers: HeaderMap,
+) -> Result, ApiError> {
+ let user = authenticated(&state, &headers, Access::Read).await?;
+ let page =
+ crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?;
+ state
+ .services
+ .unavailable_tracks(user.id, library_id, page)
+ .await
+ .map(Json)
+ .map_err(service_error)
+}
+
+/// Purges missing tracks: those named in `track_ids`, or every one with
+/// `"all": true`, and never neither (`422`). Only tracks still unavailable are
+/// removed. Not reversible: see RFC-021 decision 4 for what is lost. `409`
+/// while the library is being scanned.
+#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/unavailable-tracks/purge", tag = "catalog", params(("library_id" = Uuid, Path)), request_body = crate::services::PurgeRequest, responses((status = 200, body = crate::services::PurgeOutcome), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
+pub async fn purge_unavailable_tracks(
+ State(state): State,
+ Path(library_id): Path,
+ headers: HeaderMap,
+ Json(request): Json,
+) -> Result, ApiError> {
+ let user = authenticated(&state, &headers, Access::Write).await?;
+ state
+ .services
+ .purge_unavailable_tracks(user.id, library_id, request)
+ .await
+ .map(Json)
+ .map_err(service_error)
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 3f9cbaa8..962db211 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -108,6 +108,22 @@ pub fn router(state: AppState) -> Router {
"/api/v2/libraries",
get(list_libraries).post(create_library),
)
+ .route(
+ "/api/v2/libraries/{library_id}",
+ axum::routing::patch(update_library).delete(delete_library),
+ )
+ .route(
+ "/api/v2/libraries/{library_id}/settings",
+ get(library_settings),
+ )
+ .route(
+ "/api/v2/libraries/{library_id}/unavailable-tracks",
+ get(unavailable_tracks),
+ )
+ .route(
+ "/api/v2/libraries/{library_id}/unavailable-tracks/purge",
+ post(purge_unavailable_tracks),
+ )
.route(
"/api/v2/libraries/{library_id}/members",
get(list_library_members),
diff --git a/src/catalog.rs b/src/catalog.rs
index d085c886..d55d3e95 100644
--- a/src/catalog.rs
+++ b/src/catalog.rs
@@ -16,6 +16,14 @@ pub struct LibraryRecord {
pub root_path: PathBuf,
}
+/// A library and the one setting that changes how it is walked.
+#[derive(Debug, Clone)]
+pub struct LibraryScanSettings {
+ pub library: LibraryRecord,
+ /// Follow symbolic links that stay inside the root (RFC-021 decision 3).
+ pub follow_links: bool,
+}
+
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibraryAccess {
pub id: Uuid,
@@ -36,6 +44,10 @@ pub struct LibraryAccess {
/// read `accepts_uploads` for both would hide the canvas exactly where the
/// flag was split to allow it.
pub accepts_canvas: bool,
+ /// Whether the walk follows symbolic links that stay inside the library
+ /// (RFC-021 decision 3). An administrator's setting, shown to every
+ /// member because it explains what the library holds.
+ pub follow_links: bool,
}
/// What an incremental scan compares before reading a file (RFC-018
@@ -196,6 +208,12 @@ pub struct ScanJobRecord {
pub skipped: i64,
pub unavailable: i64,
pub errors: i64,
+ /// Entries the walk left out by an exclusion or an ignore file, a folder
+ /// counting once whatever it holds (RFC-021 decision 2).
+ pub excluded: i64,
+ /// Symbolic links the walk did not follow, for whatever reason
+ /// (RFC-021 decision 3).
+ pub skipped_links: i64,
pub current_path: Option,
pub message: Option,
}
@@ -227,7 +245,8 @@ impl Database {
) -> Result, sqlx::Error> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.visibility, m.role, l.last_scan_started_at, \
- l.last_scan_completed_at, l.accepts_uploads, l.accepts_canvas \
+ l.last_scan_completed_at, l.accepts_uploads, l.accepts_canvas, \
+ l.follow_links \
FROM library l JOIN library_member m ON m.library_id=l.id \
WHERE m.user_id=? ORDER BY l.name COLLATE NOCASE, l.id",
)
@@ -249,6 +268,7 @@ impl Database {
last_scan_completed_at: row.try_get("last_scan_completed_at")?,
accepts_uploads: row.try_get::("accepts_uploads")? != 0,
accepts_canvas: row.try_get::("accepts_canvas")? != 0,
+ follow_links: row.try_get::("follow_links")? != 0,
})
})
.collect()
@@ -261,6 +281,27 @@ impl Database {
rows.into_iter().map(library_from_row).collect()
}
+ /// One library as a scan needs it, read when the scan starts rather than
+ /// when it was queued: its root may have moved and its links setting
+ /// changed in between, and it may be gone (RFC-021 decision 5).
+ pub async fn library_scan_settings(
+ &self,
+ library_id: Uuid,
+ ) -> Result
+ {/* What the walk left out (RFC-021): only when there is something to
+ say, since most libraries have neither. */}
+ {job.excluded > 0 || job.skipped_links > 0 ? (
+
+ {missing.hasNextPage ? (
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx
index ac083b6c..1c8cd5b8 100644
--- a/webapp/src/state/i18n.tsx
+++ b/webapp/src/state/i18n.tsx
@@ -758,6 +758,51 @@ const en = {
"admin.memberAdd": "Give access to",
"admin.memberChoose": "Choose an account…",
"admin.memberError": "That membership could not be changed.",
+ "admin.manage": "Name, folder and deletion",
+ "admin.rename": "Name",
+ "admin.path": "Folder on the server",
+ "admin.followLinks":
+ "Follow symbolic links whose target stays inside the library",
+ "admin.save": "Save",
+ "admin.saved": "Saved.",
+ "admin.savedScan": "Saved. Scan {id} queued.",
+ "admin.saveError":
+ "That change could not be saved. A folder must exist on the server and must not be a symbolic link.",
+ "admin.saveBusy":
+ "The library is being scanned, or another library already uses that folder. Try again once the scan is over.",
+ "admin.delete": "Delete this library",
+ "admin.deleteNote":
+ "Removes the library from the catalogue for every member: its tracks, albums and artists, with their playlist entries, favourites and plays. No file is touched on disk. Type the library’s name to confirm.",
+ "admin.deleteConfirm": "The library’s name, to confirm",
+ "admin.deleteButton": "Delete for good",
+ "admin.deleted": "{name} was deleted: {tracks} tracks left the catalogue.",
+ "admin.deleteError": "The library could not be deleted.",
+ "scan.leftOut": "{excluded} left out · {links} links not followed",
+ "nav.missing": "Missing files",
+ "missing.total": {
+ one: "{count} file the scans can no longer find",
+ other: "{count} files the scans can no longer find",
+ },
+ "missing.nothing": "Every file is where the catalogue expects it.",
+ "missing.notYours":
+ "Only the owners and managers of this library see its missing files.",
+ "missing.note":
+ "These tracks stay in the catalogue, with their favourites, playlist entries and plays, until they are purged; a file that comes back first keeps its track. A purge is not reversible: the track, and everything that hangs off it for every member, goes for good.",
+ "missing.since": "missing since {date}",
+ "missing.cost":
+ "{playlists} in playlists · {favorites} favourites · {plays} plays",
+ "missing.select": "Select {title}",
+ "missing.purgeSelected": "Purge the selected ({count})",
+ "missing.purgeAll": "Purge them all",
+ "missing.confirm": "Confirm: purge {count}",
+ "missing.purged": {
+ one: "{count} track purged.",
+ other: "{count} tracks purged.",
+ },
+ "missing.scanning":
+ "The library is being scanned. Try again once the scan is over.",
+ "missing.failed": "The purge did not work. Try again.",
+ "missing.more": "Show more",
"scrobbling.title": "External scrobbling",
"scrobbling.detail":
"Forward what you listen to here to ListenBrainz, Maloja or Last.fm.",
@@ -1598,6 +1643,52 @@ const fr: Record = {
"admin.memberAdd": "Donner accès à",
"admin.memberChoose": "Choisir un compte…",
"admin.memberError": "Cette appartenance n’a pas pu être modifiée.",
+ "admin.manage": "Nom, dossier et suppression",
+ "admin.rename": "Nom",
+ "admin.path": "Dossier sur le serveur",
+ "admin.followLinks":
+ "Suivre les liens symboliques dont la cible reste dans la bibliothèque",
+ "admin.save": "Enregistrer",
+ "admin.saved": "Enregistré.",
+ "admin.savedScan": "Enregistré. Scan {id} ajouté à la file.",
+ "admin.saveError":
+ "Ce changement n’a pas pu être enregistré. Le dossier doit exister sur le serveur et ne pas être un lien symbolique.",
+ "admin.saveBusy":
+ "La bibliothèque est en cours d’analyse, ou une autre bibliothèque utilise déjà ce dossier. Réessayez une fois l’analyse terminée.",
+ "admin.delete": "Supprimer cette bibliothèque",
+ "admin.deleteNote":
+ "Retire la bibliothèque du catalogue pour tous ses membres : ses titres, albums et artistes, avec leurs entrées de playlist, favoris et écoutes. Aucun fichier n’est touché sur le disque. Tapez le nom de la bibliothèque pour confirmer.",
+ "admin.deleteConfirm": "Le nom de la bibliothèque, pour confirmer",
+ "admin.deleteButton": "Supprimer définitivement",
+ "admin.deleted":
+ "{name} a été supprimée : {tracks} titres ont quitté le catalogue.",
+ "admin.deleteError": "La bibliothèque n’a pas pu être supprimée.",
+ "scan.leftOut": "{excluded} écartés · {links} liens non suivis",
+ "nav.missing": "Fichiers manquants",
+ "missing.total": {
+ one: "{count} fichier que les scans ne trouvent plus",
+ other: "{count} fichiers que les scans ne trouvent plus",
+ },
+ "missing.nothing": "Chaque fichier est là où le catalogue l’attend.",
+ "missing.notYours":
+ "Seuls les propriétaires et gestionnaires de cette bibliothèque voient ses fichiers manquants.",
+ "missing.note":
+ "Ces titres restent au catalogue, avec leurs favoris, entrées de playlist et écoutes, jusqu’à leur purge ; un fichier qui revient avant garde son titre. Une purge est irréversible : le titre, et tout ce qui s’y rattache pour chaque membre, disparaît pour de bon.",
+ "missing.since": "manquant depuis le {date}",
+ "missing.cost":
+ "{playlists} en playlist · {favorites} favoris · {plays} écoutes",
+ "missing.select": "Sélectionner {title}",
+ "missing.purgeSelected": "Purger la sélection ({count})",
+ "missing.purgeAll": "Tout purger",
+ "missing.confirm": "Confirmer : purger {count}",
+ "missing.purged": {
+ one: "{count} titre purgé.",
+ other: "{count} titres purgés.",
+ },
+ "missing.scanning":
+ "La bibliothèque est en cours d’analyse. Réessayez une fois l’analyse terminée.",
+ "missing.failed": "La purge n’a pas abouti. Réessayez.",
+ "missing.more": "Afficher plus",
"scrobbling.title": "Scrobbling externe",
"scrobbling.detail":
"Transmettre ce que vous écoutez ici à ListenBrainz, Maloja ou Last.fm.",
diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css
index 9d88c91e..3860e772 100644
--- a/webapp/src/styles/styles.css
+++ b/webapp/src/styles/styles.css
@@ -3619,3 +3619,57 @@ button.danger {
.quality-spectrogram-action {
margin-top: var(--space-3);
}
+
+/* "Missing files" (RFC-021): a row is a checkbox and what it names, then when
+ it went and what a purge would take. */
+.missing-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin: var(--space-3) 0;
+}
+
+.missing-list li {
+ grid-template-columns: minmax(12rem, 1fr) auto;
+}
+
+.missing-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ min-width: 0;
+}
+
+.missing-text,
+.missing-facts {
+ display: grid;
+ min-width: 0;
+}
+
+.missing-facts {
+ justify-items: end;
+ text-align: end;
+}
+
+.missing-path {
+ overflow-wrap: anywhere;
+}
+
+/* The deletion of a library sits apart from the form that edits it, so the
+ button that saves a name is never the one beside the one that deletes. */
+.danger-zone {
+ margin-top: var(--space-4);
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--line);
+}
+
+.danger-note {
+ margin: 0;
+ font-size: var(--text-sm);
+}
+
+.control.checkbox {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
From 80245d2356bee7e003d29343dc95515a7dd4aed8 Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:32:29 +0200
Subject: [PATCH 03/13] fix(api): take administrative standing over a library
from the credential, not the account
Signed-off-by: InstaZDLL
---
src/api/access.rs | 15 ++++
src/api/libraries.rs | 10 +--
src/cli.rs | 20 +++--
src/services/library_lifecycle.rs | 67 ++++++++++++-----
src/services/mod.rs | 4 +-
tests/native_api.rs | 117 ++++++++++++++++++++++++++++++
tests/scanner.rs | 94 +++++++++++++++++++-----
7 files changed, 277 insertions(+), 50 deletions(-)
diff --git a/src/api/access.rs b/src/api/access.rs
index 0801c1da..a100fbd2 100644
--- a/src/api/access.rs
+++ b/src/api/access.rs
@@ -91,6 +91,21 @@ pub(crate) async fn authenticated(
}
}
+/// The caller of a library operation as the services weigh it (RFC-021): its
+/// account, and whether the credential it came in on carries administrative
+/// authority — the same two halves [`authenticated`] checks for
+/// [`Access::Admin`]. A route that admits a narrower credential must not let
+/// the account's role stand in for what the credential lacks.
+pub(crate) fn library_actor(
+ user: &crate::authentication::AuthUser,
+) -> crate::services::LibraryActor {
+ crate::services::LibraryActor {
+ id: user.id,
+ admin_credential: user.role == crate::database::AccountRole::Admin
+ && Access::Admin.granted_by(&user.scopes),
+ }
+}
+
/// The bearer token, whatever case the client spelled the scheme in.
///
/// RFC 7235 §2.1 makes the scheme name case-insensitive, so `bearer` is as
diff --git a/src/api/libraries.rs b/src/api/libraries.rs
index b361532e..f20754f8 100644
--- a/src/api/libraries.rs
+++ b/src/api/libraries.rs
@@ -415,7 +415,7 @@ pub async fn update_library(
let actor = authenticated(&state, &headers, access).await?;
state
.services
- .update_library(actor.id, library_id, request)
+ .update_library(library_actor(&actor), library_id, request)
.await
.map(Json)
.map_err(service_error)
@@ -435,7 +435,7 @@ pub async fn delete_library(
let actor = authenticated(&state, &headers, Access::Admin).await?;
state
.services
- .delete_library(actor.id, library_id, &request.confirm)
+ .delete_library(library_actor(&actor), library_id, &request.confirm)
.await
.map(Json)
.map_err(service_error)
@@ -451,7 +451,7 @@ pub async fn library_settings(
let actor = authenticated(&state, &headers, Access::Admin).await?;
state
.services
- .library_settings(actor.id, library_id)
+ .library_settings(library_actor(&actor), library_id)
.await
.map(Json)
.map_err(service_error)
@@ -479,7 +479,7 @@ pub async fn unavailable_tracks(
crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?;
state
.services
- .unavailable_tracks(user.id, library_id, page)
+ .unavailable_tracks(library_actor(&user), library_id, page)
.await
.map(Json)
.map_err(service_error)
@@ -499,7 +499,7 @@ pub async fn purge_unavailable_tracks(
let user = authenticated(&state, &headers, Access::Write).await?;
state
.services
- .purge_unavailable_tracks(user.id, library_id, request)
+ .purge_unavailable_tracks(library_actor(&user), library_id, request)
.await
.map(Json)
.map_err(service_error)
diff --git a/src/cli.rs b/src/cli.rs
index 50c5bd59..2453f6ea 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1055,7 +1055,7 @@ async fn rename_library(state: &AppState, args: RenameLibraryArgs) -> anyhow::Re
state
.services
.update_library(
- actor.id,
+ crate::services::LibraryActor::operator(actor.id),
args.library_id,
crate::services::LibraryUpdate {
name: Some(args.name.clone()),
@@ -1077,7 +1077,7 @@ async fn set_library_path(state: &AppState, args: SetPathArgs) -> anyhow::Result
let updated = state
.services
.update_library(
- actor.id,
+ crate::services::LibraryActor::operator(actor.id),
args.library_id,
crate::services::LibraryUpdate {
path: Some(args.path.to_string_lossy().into_owned()),
@@ -1109,7 +1109,7 @@ async fn set_library_links(state: &AppState, args: SetLinksArgs) -> anyhow::Resu
let updated = state
.services
.update_library(
- actor.id,
+ crate::services::LibraryActor::operator(actor.id),
args.library_id,
crate::services::LibraryUpdate {
follow_links: Some(args.follow),
@@ -1137,7 +1137,11 @@ async fn delete_library(state: &AppState, args: DeleteLibraryArgs) -> anyhow::Re
let actor = require_admin(&state.db, &args.actor).await?;
let deleted = state
.services
- .delete_library(actor.id, args.library_id, &args.confirm)
+ .delete_library(
+ crate::services::LibraryActor::operator(actor.id),
+ args.library_id,
+ &args.confirm,
+ )
.await
.map_err(|error| match error {
crate::services::ServiceError::Invalid => {
@@ -1161,7 +1165,11 @@ async fn list_unavailable(state: &AppState, args: UnavailableArgs) -> anyhow::Re
.map_err(|error| library_refusal(error, args.library_id))?;
let listed = state
.services
- .unavailable_tracks(actor.id, args.library_id, page)
+ .unavailable_tracks(
+ crate::services::LibraryActor::operator(actor.id),
+ args.library_id,
+ page,
+ )
.await
.map_err(|error| library_refusal(error, args.library_id))?;
if offset == 0 {
@@ -1188,7 +1196,7 @@ async fn purge_unavailable(state: &AppState, args: PurgeArgs) -> anyhow::Result<
let outcome = state
.services
.purge_unavailable_tracks(
- actor.id,
+ crate::services::LibraryActor::operator(actor.id),
args.library_id,
crate::services::PurgeRequest {
track_ids: (!args.all).then_some(args.track_id),
diff --git a/src/services/library_lifecycle.rs b/src/services/library_lifecycle.rs
index 075ff9d5..bec377c2 100644
--- a/src/services/library_lifecycle.rs
+++ b/src/services/library_lifecycle.rs
@@ -124,14 +124,42 @@ impl Standing {
}
}
+/// Who is asking, and with what authority.
+///
+/// An administrator's account is not enough to act as one: the credential the
+/// request came in on must carry administrative authority too. A token whose
+/// scopes stop short of `admin` holds only what its account's memberships
+/// give it, as `AuthUser::scopes` says a narrowed credential must.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct LibraryActor {
+ pub id: Uuid,
+ /// Whether the credential grants administrative authority. The account's
+ /// role is checked again on every operation; this can only narrow it.
+ pub admin_credential: bool,
+}
+
+impl LibraryActor {
+ /// The operator at the CLI, whose authority is the account's own.
+ pub fn operator(id: Uuid) -> Self {
+ Self {
+ id,
+ admin_credential: true,
+ }
+ }
+}
+
/// The caller's standing on a library, read on the connection the operation
/// runs on. A library that does not exist, and one the caller is neither a
/// member of nor an administrator over, are the same answer: not found.
+///
+/// Administrative standing needs both halves: an active administrator's
+/// account, and a credential that carries that authority.
async fn standing_on(
connection: &mut SqliteConnection,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
) -> Result {
+ let actor_id = actor.id;
let row = sqlx::query(
"SELECT (SELECT role FROM library_member WHERE library_id = l.id AND user_id = ?) AS role, \
EXISTS (SELECT 1 FROM account a WHERE a.id = ? AND a.role = 'admin' \
@@ -144,7 +172,7 @@ async fn standing_on(
.fetch_optional(&mut *connection)
.await?
.ok_or(ServiceError::NotFound)?;
- if row.try_get::("admin")? != 0 {
+ if actor.admin_credential && row.try_get::("admin")? != 0 {
return Ok(Standing::Admin);
}
match row.try_get::
, _>("role")?.as_deref() {
@@ -384,10 +412,13 @@ impl DomainServices {
/// The settings an administrator manages, the root included.
pub async fn library_settings(
&self,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
) -> Result {
- self.require_admin(actor_id).await?;
+ if !actor.admin_credential {
+ return Err(ServiceError::Forbidden);
+ }
+ self.require_admin(actor.id).await?;
let settings = self
.db
.library_scan_settings(library_id)
@@ -411,7 +442,7 @@ impl DomainServices {
/// Either refuses while the library is being scanned.
pub async fn update_library(
&self,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
update: LibraryUpdate,
) -> Result {
@@ -420,7 +451,7 @@ impl DomainServices {
}
let standing = {
let mut connection = self.db.pool().acquire().await?;
- standing_on(&mut connection, actor_id, library_id).await?
+ standing_on(&mut connection, actor, library_id).await?
};
let allowed = if update.is_administrative() {
standing == Standing::Admin
@@ -493,7 +524,7 @@ impl DomainServices {
if let Some(name) = name.as_deref().filter(|name| *name != before.library.name) {
audit(
&mut tx,
- Some(actor_id),
+ Some(actor.id),
"library.renamed",
library_id,
serde_json::json!({ "name": name }),
@@ -503,7 +534,7 @@ impl DomainServices {
if moved {
audit(
&mut tx,
- Some(actor_id),
+ Some(actor.id),
"library.moved",
library_id,
serde_json::json!({}),
@@ -513,7 +544,7 @@ impl DomainServices {
if relinked {
audit(
&mut tx,
- Some(actor_id),
+ Some(actor.id),
"library.follow_links_set",
library_id,
serde_json::json!({ "follow_links": update.follow_links }),
@@ -538,7 +569,7 @@ impl DomainServices {
.db
.create_scan_job(
library_id,
- Some(actor_id),
+ Some(actor.id),
if moved { "library_moved" } else { "manual" },
)
.await?;
@@ -560,13 +591,13 @@ impl DomainServices {
/// the commit.
pub async fn delete_library(
&self,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
confirm: &str,
) -> Result {
let standing = {
let mut connection = self.db.pool().acquire().await?;
- standing_on(&mut connection, actor_id, library_id).await?
+ standing_on(&mut connection, actor, library_id).await?
};
if standing != Standing::Admin {
return Err(ServiceError::Forbidden);
@@ -639,7 +670,7 @@ impl DomainServices {
}
audit(
&mut tx,
- Some(actor_id),
+ Some(actor.id),
"library.deleted",
library_id,
serde_json::json!({
@@ -678,12 +709,12 @@ impl DomainServices {
/// first, with what purging each would cost (RFC-021 decision 4).
pub async fn unavailable_tracks(
&self,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
page: BrowsePage,
) -> Result {
let mut connection = self.db.pool().acquire().await?;
- if !standing_on(&mut connection, actor_id, library_id)
+ if !standing_on(&mut connection, actor, library_id)
.await?
.curates()
{
@@ -741,7 +772,7 @@ impl DomainServices {
/// scan may be about to relocate one of these onto a file it just found.
pub async fn purge_unavailable_tracks(
&self,
- actor_id: Uuid,
+ actor: LibraryActor,
library_id: Uuid,
request: PurgeRequest,
) -> Result {
@@ -760,14 +791,14 @@ impl DomainServices {
let purged = {
let _writer = self.db.writer_guard().await;
let mut tx = self.db.pool().begin().await?;
- if !standing_on(&mut tx, actor_id, library_id).await?.curates() {
+ if !standing_on(&mut tx, actor, library_id).await?.curates() {
return Err(ServiceError::Forbidden);
}
let purged = purge_in(&mut tx, library_id, &selection).await?;
if purged.tracks > 0 {
audit(
&mut tx,
- Some(actor_id),
+ Some(actor.id),
"library.tracks_purged",
library_id,
serde_json::json!({ "tracks": purged.tracks }),
diff --git a/src/services/mod.rs b/src/services/mod.rs
index 93239bfd..e7365ae0 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -1469,8 +1469,8 @@ pub use library_folders::{
MAX_FOLDER_DEPTH, MAX_PLACEMENTS,
};
pub use library_lifecycle::{
- LibraryDeleted, LibrarySettings, LibraryUpdate, LibraryUpdated, PurgeOutcome, PurgeRequest,
- UnavailableTrack, UnavailableTracks,
+ LibraryActor, LibraryDeleted, LibrarySettings, LibraryUpdate, LibraryUpdated, PurgeOutcome,
+ PurgeRequest, UnavailableTrack, UnavailableTracks,
};
pub use library_tree::{DiskFolder, DiskListing, DiskTrack};
pub use lyrics_uploads::{LyricsFile, MAX_UPLOAD_BYTES as MAX_LYRICS_UPLOAD_BYTES};
diff --git a/tests/native_api.rs b/tests/native_api.rs
index bd532113..3a016ad2 100644
--- a/tests/native_api.rs
+++ b/tests/native_api.rs
@@ -5847,3 +5847,120 @@ async fn the_library_lifecycle_routes_answer_by_standing() {
.await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
+
+/// An administrator's account is not enough: a token whose scopes stop short
+/// of `admin` acts with the account's memberships only, so it cannot rename,
+/// purge or delete a library its account is not a member of — while the same
+/// account on an unrestricted credential can.
+#[tokio::test]
+async fn a_narrowed_admin_token_has_no_say_over_a_foreign_library() {
+ let (_temp, config, state) = test_app().await;
+ let (admin, full_token) = account_with_token(&state, "scoped-admin", "admin").await;
+ let (owner, _) = account_with_token(&state, "scoped-owner", "user").await;
+ let write_token = security::generate_token("wfapi_");
+ state
+ .db
+ .create_api_token(
+ waveflow_server::database::NewApiToken {
+ actor_id: admin,
+ user_id: admin,
+ name: "write only",
+ token_hash: &security::token_hash(&write_token),
+ scopes: &["write".to_owned()],
+ expires_at: None,
+ },
+ now_ms(),
+ )
+ .await
+ .unwrap();
+ let music = config.data_dir.join("scoped-music");
+ std::fs::create_dir_all(&music).unwrap();
+ let root = std::fs::canonicalize(&music).unwrap();
+ let library_id = state
+ .db
+ .create_library(
+ owner,
+ "Foreign",
+ &root,
+ LibraryVisibility::Private,
+ now_ms(),
+ )
+ .await
+ .unwrap();
+ let router = waveflow_server::app(&config, state.clone());
+ let library = format!("/api/v2/libraries/{library_id}");
+ let send = |request: Request| {
+ let router = router.clone();
+ async move { router.oneshot(request).await.unwrap().status() }
+ };
+
+ assert_eq!(
+ send(bearer_json(
+ "PATCH",
+ &library,
+ &write_token,
+ serde_json::json!({"name": "Taken"}),
+ ))
+ .await,
+ StatusCode::NOT_FOUND,
+ "a narrowed token renamed a library its account is not a member of"
+ );
+ assert_eq!(
+ send(bearer_json(
+ "POST",
+ &format!("{library}/unavailable-tracks/purge"),
+ &write_token,
+ serde_json::json!({"all": true}),
+ ))
+ .await,
+ StatusCode::NOT_FOUND
+ );
+ assert_eq!(
+ send(bearer_json(
+ "GET",
+ &format!("{library}/unavailable-tracks"),
+ &write_token,
+ serde_json::json!({}),
+ ))
+ .await,
+ StatusCode::NOT_FOUND
+ );
+ assert_eq!(
+ send(bearer_json(
+ "DELETE",
+ &library,
+ &write_token,
+ serde_json::json!({"confirm": "Foreign"}),
+ ))
+ .await,
+ StatusCode::FORBIDDEN
+ );
+ let name: String = sqlx::query_scalar("SELECT name FROM library WHERE id = ?")
+ .bind(library_id.to_string())
+ .fetch_one(state.db.pool())
+ .await
+ .unwrap();
+ assert_eq!(name, "Foreign");
+
+ // The same account, unrestricted: its authority is an administrator's.
+ assert_eq!(
+ send(bearer_json(
+ "PATCH",
+ &library,
+ &full_token,
+ serde_json::json!({"name": "Renamed by the operator"}),
+ ))
+ .await,
+ StatusCode::OK
+ );
+ assert_eq!(
+ send(bearer_json(
+ "DELETE",
+ &library,
+ &full_token,
+ serde_json::json!({"confirm": "Renamed by the operator"}),
+ ))
+ .await,
+ StatusCode::OK
+ );
+}
diff --git a/tests/scanner.rs b/tests/scanner.rs
index 01fd65b6..88302a8d 100644
--- a/tests/scanner.rs
+++ b/tests/scanner.rs
@@ -1703,7 +1703,7 @@ async fn links_are_followed_only_inside_the_library() {
let updated = state
.services
.update_library(
- owner,
+ waveflow_server::services::LibraryActor::operator(owner),
library.id,
waveflow_server::services::LibraryUpdate {
follow_links: Some(true),
@@ -1872,7 +1872,7 @@ async fn a_purge_removes_only_what_is_still_missing() {
let listed = state
.services
.unavailable_tracks(
- owner,
+ waveflow_server::services::LibraryActor::operator(owner),
library.id,
waveflow_server::services::BrowsePage::default(),
)
@@ -1896,7 +1896,7 @@ async fn a_purge_removes_only_what_is_still_missing() {
let purged = state
.services
.purge_unavailable_tracks(
- owner,
+ waveflow_server::services::LibraryActor::operator(owner),
library.id,
waveflow_server::services::PurgeRequest {
track_ids: Some(vec![first.parse().unwrap(), second.parse().unwrap()]),
@@ -1955,7 +1955,11 @@ async fn a_purge_is_explicit_and_waits_for_no_scan() {
let neither = state
.services
- .purge_unavailable_tracks(owner, library.id, PurgeRequest::default())
+ .purge_unavailable_tracks(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ PurgeRequest::default(),
+ )
.await;
assert!(matches!(neither, Err(ServiceError::Invalid)), "{neither:?}");
@@ -1978,14 +1982,22 @@ async fn a_purge_is_explicit_and_waits_for_no_scan() {
assert!(matches!(
state
.services
- .purge_unavailable_tracks(listener, library.id, all())
+ .purge_unavailable_tracks(
+ waveflow_server::services::LibraryActor::operator(listener),
+ library.id,
+ all()
+ )
.await,
Err(ServiceError::Forbidden)
));
assert!(matches!(
state
.services
- .unavailable_tracks(listener, library.id, Default::default())
+ .unavailable_tracks(
+ waveflow_server::services::LibraryActor::operator(listener),
+ library.id,
+ Default::default()
+ )
.await,
Err(ServiceError::Forbidden)
));
@@ -1994,7 +2006,11 @@ async fn a_purge_is_explicit_and_waits_for_no_scan() {
assert!(matches!(
state
.services
- .purge_unavailable_tracks(owner, library.id, all())
+ .purge_unavailable_tracks(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ all()
+ )
.await,
Err(ServiceError::Conflict)
));
@@ -2044,7 +2060,7 @@ async fn a_moved_root_keeps_every_identity() {
let updated = state
.services
.update_library(
- owner,
+ waveflow_server::services::LibraryActor::operator(owner),
library.id,
waveflow_server::services::LibraryUpdate {
path: Some(moved.to_string_lossy().into_owned()),
@@ -2115,7 +2131,11 @@ async fn the_lifecycle_follows_the_role_model() {
state
.services
- .update_library(owner, library.id, rename(" Renamed "))
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ rename(" Renamed "),
+ )
.await
.unwrap();
let name: String = sqlx::query_scalar("SELECT name FROM library WHERE id = ?")
@@ -2127,14 +2147,22 @@ async fn the_lifecycle_follows_the_role_model() {
assert!(matches!(
state
.services
- .update_library(listener, library.id, rename("Mine now"))
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(listener),
+ library.id,
+ rename("Mine now")
+ )
.await,
Err(ServiceError::Forbidden)
));
assert!(matches!(
state
.services
- .update_library(owner, library.id, rename(" "))
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ rename(" ")
+ )
.await,
Err(ServiceError::Invalid)
));
@@ -2147,14 +2175,22 @@ async fn the_lifecycle_follows_the_role_model() {
assert!(matches!(
state
.services
- .update_library(owner, library.id, repath.clone())
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ repath.clone()
+ )
.await,
Err(ServiceError::Forbidden)
));
assert!(matches!(
state
.services
- .delete_library(owner, library.id, "Renamed")
+ .delete_library(
+ waveflow_server::services::LibraryActor::operator(owner),
+ library.id,
+ "Renamed"
+ )
.await,
Err(ServiceError::Forbidden)
));
@@ -2162,14 +2198,22 @@ async fn the_lifecycle_follows_the_role_model() {
assert!(matches!(
state
.services
- .update_library(admin, library.id, repath)
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(admin),
+ library.id,
+ repath
+ )
.await,
Err(ServiceError::Conflict)
));
assert!(matches!(
state
.services
- .delete_library(admin, library.id, "Renamed")
+ .delete_library(
+ waveflow_server::services::LibraryActor::operator(admin),
+ library.id,
+ "Renamed"
+ )
.await,
Err(ServiceError::Conflict)
));
@@ -2178,7 +2222,7 @@ async fn the_lifecycle_follows_the_role_model() {
state
.services
.update_library(
- admin,
+ waveflow_server::services::LibraryActor::operator(admin),
library.id,
LibraryUpdate {
path: Some(temp.path().join("absent").to_string_lossy().into_owned()),
@@ -2193,7 +2237,11 @@ async fn the_lifecycle_follows_the_role_model() {
assert!(matches!(
state
.services
- .update_library(outsider, library.id, rename("Ours"))
+ .update_library(
+ waveflow_server::services::LibraryActor::operator(outsider),
+ library.id,
+ rename("Ours")
+ )
.await,
Err(ServiceError::NotFound)
));
@@ -2246,7 +2294,11 @@ async fn a_deleted_library_leaves_no_row_behind() {
assert!(matches!(
state
.services
- .delete_library(admin, library.id, "Deleted")
+ .delete_library(
+ waveflow_server::services::LibraryActor::operator(admin),
+ library.id,
+ "Deleted"
+ )
.await,
Err(ServiceError::Invalid)
));
@@ -2254,7 +2306,11 @@ async fn a_deleted_library_leaves_no_row_behind() {
let deleted = state
.services
- .delete_library(admin, library.id, "deleted")
+ .delete_library(
+ waveflow_server::services::LibraryActor::operator(admin),
+ library.id,
+ "deleted",
+ )
.await
.unwrap();
assert_eq!(deleted.tracks, 3);
From f1aebeafa8ea70e63c27c6ff35a6ad311023e5ba Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:33:23 +0200
Subject: [PATCH 04/13] docs(rfc): say that administrative standing over a
library comes from the credential
Signed-off-by: InstaZDLL
---
docs/rfcs/RFC-021-library-lifecycle-and-scan.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/rfcs/RFC-021-library-lifecycle-and-scan.md b/docs/rfcs/RFC-021-library-lifecycle-and-scan.md
index 685dd99d..2292bb03 100644
--- a/docs/rfcs/RFC-021-library-lifecycle-and-scan.md
+++ b/docs/rfcs/RFC-021-library-lifecycle-and-scan.md
@@ -220,6 +220,14 @@ library is: they decide which part of the server's file system the server
reads. A `PATCH` naming either needs an administrative credential, not only an
administrator's account; a rename needs a credential that may write.
+**Administrative standing comes from the credential as well as the account**,
+on every operation here, the missing-tracks view and purge included. An
+administrator whose token's scopes stop short of `admin` holds only the
+memberships of the account behind it: such a token cannot rename, purge or
+list the missing tracks of a library that account is not a member of, and the
+deletion refuses it outright. That is the rule `AuthUser::scopes` states for
+every narrowed credential.
+
**Changing the path** validates the new one as creating a library does (an
existing directory, not itself a link, stored canonical, not another library's
root), then queues a scan with the new trigger `library_moved`. Tracks are
From 639bf72a8532fb4188e07933ffd1d25247db3c0a Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:57:37 +0200
Subject: [PATCH 05/13] test(scanner): a queued scan walks the library as it is
when it runs
Signed-off-by: InstaZDLL
---
tests/scanner.rs | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/tests/scanner.rs b/tests/scanner.rs
index 88302a8d..74ccb6a9 100644
--- a/tests/scanner.rs
+++ b/tests/scanner.rs
@@ -2396,3 +2396,35 @@ async fn a_watched_change_is_scanned_once_it_settles() {
assert_eq!(watched, Some(1), "no scan followed the change");
assert_eq!(available_paths(&state).await, ["First.wav", "Second.wav"]);
}
+
+/// RFC-021 decision 5: a scan queued before a move walks the library as it is
+/// when it runs, not as it was when it was queued — the watcher and the
+/// interval queue scans that a move can overtake.
+#[tokio::test]
+async fn a_queued_scan_reads_the_library_as_it_is_when_it_runs() {
+ let (temp, state, owner, library, root) = library_with_albums("queued").await;
+ let held = state.scanner.try_lock_library(library.id).unwrap();
+ // Queued with the old root, and waiting for the lock.
+ let queued = state
+ .scanner
+ .trigger(library.clone(), Some(owner), "manual")
+ .await
+ .unwrap();
+ let moved = temp.path().join("queued-elsewhere");
+ std::fs::rename(&root, &moved).unwrap();
+ sqlx::query("UPDATE library SET root_path = ? WHERE id = ?")
+ .bind(
+ std::fs::canonicalize(&moved)
+ .unwrap()
+ .to_string_lossy()
+ .into_owned(),
+ )
+ .bind(library.id.to_string())
+ .execute(state.db.pool())
+ .await
+ .unwrap();
+ drop(held);
+ let record = settled(&state, owner, queued).await;
+ assert_eq!(record.status, "completed", "{:?}", record.message);
+ assert_eq!((record.added, record.unavailable), (0, 0));
+}
From cefda815a218121e4f36b5c84653717c9399d9bd Mon Sep 17 00:00:00 2001
From: InstaZDLL
Date: Sun, 25 Oct 2026 02:08:31 +0100
Subject: [PATCH 06/13] fix(webapp): draw the follow-links setting as a
sentence beside its box
Signed-off-by: InstaZDLL
---
webapp/src/pages/admin-page.tsx | 3 ++-
webapp/src/styles/styles.css | 12 +++++++++++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/webapp/src/pages/admin-page.tsx b/webapp/src/pages/admin-page.tsx
index bcc70a9a..c9615ea7 100644
--- a/webapp/src/pages/admin-page.tsx
+++ b/webapp/src/pages/admin-page.tsx
@@ -583,9 +583,10 @@ function LibrarySettingsPanel({
required
/>
-