Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ bun run build # webapp first, then cargo — the order

Tests need no service container: `test_app()` builds a `Config` over a `TempDir` and the suite drives the same router `main` serves. FFmpeg and ffprobe must be on `PATH`.

The integration suite is nineteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says.
The integration suite is twenty targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `quality`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says.

`cargo build` works without a client build — `build.rs` embeds a placeholder page when `webapp/dist` is absent. A real `vite build` overwrites it.

Expand All @@ -66,6 +66,7 @@ The integration suite is nineteen targets under `tests/`, one per subject — `a
- **Native clients use Authorization Code + PKCE** (`src/oauth.rs`). S256 only; redirect targets restricted to loopback, https or a reverse-domain private scheme. A code is spent by its first presentation whatever the outcome.
- **The web client is a router fallback** (`src/webui.rs`). Anything not claimed by an API route is a built asset or a client-side route. Paths under `api/`, `rest/`, `share/` and the probes are excluded, so a mistyped API call stays a JSON 404 instead of returning HTML.
- **Audio files are read-only.** The scanner and tag operations never rewrite them.
- **The real quality of a file is measured, never read** ([RFC-016](docs/rfcs/RFC-016-audio-quality-analysis.md)). `src/quality/` decodes a lossless file through FFmpeg and runs the desktop's wall search on it, ported from `waveflow-core` and held to it by a test; `src/services/quality.rs` decides which files are measured and what a measurement means. The analysis runs in the background, `WAVEFLOW_QUALITY_PARALLELISM` files at a time (one by default, zero turns it off), and starts no file while a scan runs. A row in `track_quality` is current only for the content, the analyser version and the FFmpeg build that made it; boot deletes the others, so a read can call a row current on its `full_hash` alone. The verdict is SQL computed where it is read, never a column.
- **The Subsonic contract is frozen** for v2.0-beta — see the "Frozen Subsonic v2.0-beta contract" section of RFC-002 and [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md). Changing observable wire behaviour there risks the clients already validated against it.
- **Logging.** `tracing` + `RUST_LOG`; `WAVEFLOW_LOG_FORMAT=json` for prod. Traces record `uri.path()` only, with share tokens and stream tickets redacted. Never log headers, query strings, tokens or passwords.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ md-5 = "0.11"
# percent escapes out of playlist entries and a transitive dependency is not a
# promise.
percent-encoding = "2"
realfft = "3"
rand = "0.10"
# The feature set is `waveflow-core`'s, deliberately, minus `blocking` which
# nothing here needs. That crate already pulls reqwest over rustls into this
Expand Down Expand Up @@ -89,6 +90,7 @@ waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "102df003

[dev-dependencies]
http-body-util = "0.1"
realfft = "3"
tempfile = "3"
tokio-tungstenite = "0.30"

Expand Down Expand Up @@ -172,3 +174,7 @@ path = "tests/enrichment.rs"
[[test]]
name = "credits"
path = "tests/credits.rs"

[[test]]
name = "quality"
path = "tests/quality.rs"
54 changes: 39 additions & 15 deletions docs/rfcs/RFC-016-audio-quality-analysis.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
# RFC-016 — The real quality of a file, measured from its audio

- **Status**: Proposed
- **Implemented by**: in progress, in the order of "The order of the work"
below: [#317](https://github.com/InstaZDLL/waveflow-server/pull/317) (this
RFC). The *Status* field above never flips in this
project; this line is the one that says what is running.
- **Implemented by**: [#317](https://github.com/InstaZDLL/waveflow-server/pull/317)
(this RFC) and [#319](https://github.com/InstaZDLL/waveflow-server/pull/319)
(steps 2 to 4 together: the migration, the ported detector and the FFmpeg
pipe, the background analyser, the verdicts, the five routes, the "Needs
attention" category, the Quality page and the track properties' "Real
quality" section). The *Status* field above never flips in this project;
this line is the one that says what is running.
- **Date**: 2026-10-24
- **Revised**: 2026-10-25, with the implementation. `upsampled` stops at
30 kHz rather than 24, as measured on FFmpeg's own resampler (decision 3),
and decision 3 says which upsampled files it cannot see.
- **Authors**: the WaveFlow project
- **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md) (FFmpeg, read-only
audio, the path guard, tenancy in the queries),
Expand Down Expand Up @@ -265,21 +271,35 @@ In order, the first that applies:
| `unreadable` | FFmpeg failed or ran past its deadline on this content | "Could not be read" |
| `silent` | no frame above −60 dBFS | "Silent" |
| `lossy_source` | `probable_fake_lossless_sql!`: a wall at or under `MIN(21000, sample_rate / 2 − 1000)` | "Probably from a lossy file", with the cutoff |
| `upsampled` | `sample_rate > 48000` and a wall at or under 24 kHz | "Probably upsampled", with the cutoff |
| `upsampled` | `sample_rate > 48000` and a wall at or under 30 kHz | "Probably upsampled", with the cutoff |
| `padded` | `effective_bits < bit_depth` | "Padded to N bits", with the real depth |
| `consistent` | none of the above | "No sign of a lossy source" |

- **`lossy_source` before `upsampled`.** A 96 kHz file with a wall at 16 kHz
came from an MP3 first; that it was upsampled afterwards is the lesser
news. Between 21 and 24 kHz the two cannot be confused: no lossy encoder
cuts there, and a CD converter's filter does.
- **`upsampled` is new**, and its threshold is not the desktop's tuning. 24 kHz
is the Nyquist of a 48 kHz source, so a file above 48 kHz whose highs stop
at or under it holds nothing a 48 kHz file could not. The wall test is the
news. Above 21 kHz the two cannot be confused: no lossy encoder cuts
there, and a resampler's filter does.
- **`upsampled` is new**, and its threshold is not the desktop's tuning. The
first draft put it at 24 kHz, the Nyquist of a 48 kHz source, on the
reasoning that a file above 48 kHz whose highs stop there holds nothing a
48 kHz file could not. Measured, a resampler's filter does not stop dead
at the source's Nyquist. On white noise through FFmpeg's own resampler,
the wall stands at 21.5 to 23.8 kHz with a steep filter
(`filter_size=64:cutoff=0.91`), and at 25.7 kHz from 44.1 kHz and 28.0 kHz
from 48 kHz with its default one, whose transition runs past the source's
Nyquist. The threshold is 30 kHz, which holds all of them. A genuine
96 kHz recording of the same noise has no wall at all. The wall test is the
same as for `lossy_source`, which is what protects a genuine hi-res
recording that simply has little above 24 kHz: it fades, it does not hit a
wall. The implementation measures it on upsampled fixtures before it ships
(step 2's tests), and the page says "probably".
recording that simply has little up high: it fades, it does not hit a
wall. And the page says "probably".
- **What `upsampled` cannot see.** The same three minutes of real music
through FFmpeg's default resampler show no wall at all: that filter lets
through images of the music's own highs, mirrored above the source's
Nyquist, and they come back within 15 dB of the level under the wall,
which the desktop's rule reads, rightly, as content. Such a file is
`consistent`. A steeper resampler leaves the wall, at 21.5 kHz on the
same music, and is found. As with encoders that do not low-pass, no
measure of where the highs stop finds what does not stop.
- **`padded` is the mildest**, and comes after the other two for that reason: a 16-bit file in a
24-bit container is still a lossless CD. It is reported because it is a
claim the container makes and the audio contradicts, and because it is how
Expand Down Expand Up @@ -324,7 +344,9 @@ a library's backlog, and the analysis is never urgent.

**How long one file may take.** A deadline of 60 seconds plus the track's own
duration, which asks the decoder for real time where it ran 650 times faster
here, so only a stuck process meets it. After it, the process is killed
here, so only a stuck process meets it, and never more than 30 minutes: the
duration is what the file's header claims, and a crafted file claiming ten
hours that hangs FFmpeg must not hold a slot for ten hours. After it, the process is killed
(`kill_on_drop`) and the file recorded `unreadable` for this content. A file
that cannot be read is not retried on every wake, which would loop forever on
one bad file; it is retried when its content changes or the detector's
Expand Down Expand Up @@ -511,7 +533,9 @@ by `WAVEFLOW_QUALITY_PARALLELISM` plus one, so a reader can still have one
drawn while the background analysis runs; beyond that the route answers
`429` with `Retry-After`. Access is checked before the bound, so an account
that may not read the track gets `404` and never learns from a `429` that
something is there. It has a 30-second deadline. Unlike the analysis,
something is there. It has a 20-second deadline, and less when the
request timeout leaves less, so the route answers its own `503` before the
router's timeout answers for it. Unlike the analysis,
it is on the answering path, because a picture someone clicked for is the
answer; that is why it is bounded and never computed for anyone who did not
ask. It is drawn for any file, lossy ones included, because a reader may want
Expand Down
35 changes: 35 additions & 0 deletions migrations-v2/20261025000000_track_quality.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- The real quality of a file, measured from its audio (RFC-016).
--
-- One measurement per track. It describes the content whose `full_hash` it
-- carries, taken by the analyser generation `version`, through the FFmpeg
-- build whose `ffmpeg -version` output hashes to `decoder`: a row whose hash
-- is no longer the track's is about a file that has gone, and a row from
-- another generation or another FFmpeg is about samples the server would not
-- produce today. Either way the track reads as `pending` until it is measured
-- again.
--
-- No verdict is stored. The verdict is SQL over these numbers and the
-- track's own columns, computed where it is read, so a change to its rules is
-- a code change and never a re-analysis.
CREATE TABLE track_quality (
track_id TEXT PRIMARY KEY NOT NULL REFERENCES track(id) ON DELETE CASCADE,
full_hash TEXT NOT NULL CHECK (length(full_hash) > 0),
version INTEGER NOT NULL CHECK (version > 0),
decoder TEXT NOT NULL CHECK (length(decoder) = 64),
outcome TEXT NOT NULL CHECK (outcome IN ('measured', 'silent', 'unreadable')),
-- Where the wall stands, in hertz; NULL when the spectrum has none.
cutoff_hz REAL,
-- How far the level falls across the steepest candidate, wall or not.
wall_db REAL,
-- The bits the samples use; NULL for floating point, or all zero.
effective_bits INTEGER CHECK (effective_bits BETWEEN 1 AND 32),
-- The averaged spectrum: 256 bands from 0 Hz to Nyquist, one byte each,
-- in half-decibels under the loudest band.
spectrum BLOB CHECK (spectrum IS NULL OR length(spectrum) = 256),
-- Why a file was unreadable. Never a message: FFmpeg's own words can carry
-- the path.
failure TEXT CHECK (failure IN ('decode', 'timeout')),
analyzed_at INTEGER NOT NULL,
CHECK ((outcome = 'measured') = (spectrum IS NOT NULL)),
CHECK ((outcome = 'unreadable') = (failure IS NOT NULL))
) STRICT;
19 changes: 19 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod oauth;
mod playback;
mod playlists;
mod probes;
mod quality;
mod radio;
mod scrobbling;
mod setup;
Expand All @@ -60,6 +61,7 @@ pub use oauth::*;
pub use playback::*;
pub use playlists::*;
pub use probes::*;
pub use quality::*;
pub use radio::*;
pub use scrobbling::*;
pub use setup::*;
Expand Down Expand Up @@ -156,6 +158,23 @@ pub fn router(state: AppState) -> Router {
"/api/v2/libraries/{library_id}/credit-decisions/{decision_id}",
delete(delete_credit_decision),
)
.route(
"/api/v2/libraries/{library_id}/quality",
get(quality_summary),
)
.route(
"/api/v2/libraries/{library_id}/quality/tracks",
get(quality_tracks),
)
.route(
"/api/v2/libraries/{library_id}/quality/analysis",
post(request_quality_analysis),
)
.route("/api/v2/tracks/{track_id}/quality", get(track_quality))
.route(
"/api/v2/tracks/{track_id}/spectrogram",
get(track_spectrogram),
)
.route("/api/v2/libraries/{library_id}/events", get(library_events))
.route(
"/api/v2/libraries/{library_id}/events/ack",
Expand Down
Loading
Loading