diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..78af25e5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,167 @@
+# Changelog
+
+Notable changes to WaveFlow Server. Dates are the release date; the version is
+the one `Cargo.toml` carries and the tag `release.yml` refuses to publish if the
+two disagree.
+
+The desktop app at [`InstaZDLL/WaveFlow`](https://github.com/InstaZDLL/WaveFlow)
+consumes `/api/v2`, so anything that changes the shape of a response is called
+out first, whether or not it breaks a compile.
+
+## [2.0.0-beta.1] — 2026-09-16
+
+361 commits since `2.0.0-beta.0`.
+
+### Changed — response shapes
+
+Three changes a client must follow — two on `/api/v2`, which the desktop app
+consumes, and one on the Subsonic façade.
+
+- **`GET /api/v2/scrobble-destinations`** — the `unavailable` field now carries
+ a **case**, not an English sentence: `no_application_configured` or
+ `browser_journey_needs_https`. It was printed verbatim into a client that
+ ships in two languages, which told a French reader in English what to change
+ in their configuration. The wording now belongs to whoever is doing the
+ telling.
+
+- **`GET /api/v2/scans/{id}/events`** — the `progress` frame now speaks the same
+ shape as `GET /api/v2/scans/{id}`, where it used to send a second shape of the
+ same reading: `id`, `total_files` and `processed_files` instead of `scan_id`,
+ `total` and `processed`. A watcher that bound `total_files` showed `undefined`
+ from the first progress frame on. The type behind it is deliberately no longer
+ serialisable, so the other shape cannot come back.
+
+- **`getPlayQueue` and `getNowPlaying`** name their children **`entry`**, not
+ `song`. The Subsonic schema renames a media item inside those two containers
+ exactly as it does inside a playlist, and this server sent `song` in both
+ until now. A client that decodes against the schema read an empty queue and an
+ empty now-playing list; three client campaigns passed over it because every
+ client that met it was lenient. `playQueue` also gains the **`username`** the
+ schema requires beside `changed` and `changedBy`.
+
+ Both names are deliberately **not** emitted together: a response carrying
+ `entry` *and* `song` would conform to neither contract and would need a second
+ wire change later to undo. The freeze on the Subsonic contract protects the
+ clients that were validated against it, not a divergence from the
+ specification — which is what a beta is for.
+
+ `getPlayQueue` with nothing saved still answers a bare `playQueue`, unchanged
+ and not schema-conforming. Making it so means omitting the element, which is a
+ further wire change on the one call every client makes at startup; it is
+ pinned by a test and left to decide after the beta, on #229.
+
+### Added
+
+- **External scrobbling.** ListenBrainz, Last.fm and Maloja, each a named
+ instance whose URL is part of its identity. A listen leaves by a durable
+ queue that survives a restart, is claimed before it is emitted, retries once,
+ honours a standard `Retry-After`, and reports a health that cannot read
+ `healthy` while three thousand listens wait. Last.fm authorises through a
+ browser, and a machine with no browser on it has its own journey.
+- **Canvas** — the looping visual a track can carry: its own store, six routes,
+ a ticket, an event, and a sweep that collects what a failed upload left behind.
+- **Uploads** — a library can be told to accept files; the server decides
+ whether it wants one, receives it in chunks under a bounded session, and makes
+ it a track.
+- **Track corrections** — a track's tags, artists and genres can be corrected
+ without rewriting the file. A correction leaves alone what it does not
+ mention, and an empty patch writes nothing and announces nothing.
+- **A library change feed** — `/api/v2` gained a change feed with a watermark a
+ device acknowledges for itself, a retention trim, and an answer that says
+ where the stream was cut.
+- **Subsonic album output fields** — `originalReleaseDate`, `releaseDate`,
+ `releaseTypes[]`, `recordLabels[]` and `discTitles[]` are answered where the
+ tags carry them.
+- **Search scoped to one library**, on both surfaces.
+- **The web client** grew the screens it was missing: uploads, canvas placement,
+ the track-tag editor, a live view of a running scan, API tokens, library
+ membership, sort and filter on the browse pages, player modes, and the four
+ gestures external scrobbling leaves to a client.
+
+### Changed
+
+- **The Subsonic façade is split by method family** rather than living in one
+ module, and the domain services are split by domain. Seven defects introduced
+ by the move were found and fixed before this release; the façade's observable
+ behaviour is unchanged except where this file says otherwise.
+- **A song is not a release** — a track no longer answers with fields that
+ belong to the album it sits on.
+
+### Fixed
+
+Four of these were found by the client campaigns of 2026-09-16 and cleared
+before the tag. All four shipped in `2.0.0-beta.0`; none is a regression this
+release introduces.
+
+- **A role separator no longer cuts inside parentheses** (#224). A `COMPOSER`
+ reading `Kobee (Melange / INHOUSE), Holy M (Melange / INHOUSE)` names two
+ people; the two slashes made three entities, each carrying a parenthesis it
+ never opened, and they reached the catalogue as artists that search answered
+ with. The reference's rule is untouched — `Bach/Gounod` is still two people
+ and `AC/DC` is still one band.
+- **`getPlayQueue` and `getNowPlaying` name their children `entry`** (#225), and
+ `playQueue` carries `username`. See the response-shape section above: this one
+ changes the wire.
+- **A `416` no longer announces a length of zero** (#226). A transcode still
+ being produced has no complete length, and `bytes */0` did not say "unknown" —
+ it said the resource is empty, which a client may believe and never ask about
+ again. The header is now omitted instead. A complete file, whose length *is*
+ known, states it and answers `Accept-Ranges: bytes`, where it used to claim
+ `none` and contradict every other answer for the same file.
+- **A refusal that outlived its session neither renews nor replays** (#219). A
+ sign-out and a sign-in fit inside one round trip, so a 401 raised for one
+ account could arrive after another had signed in — renewing spent the new
+ account's rotating refresh token for a request the old one made. Five routes
+ did this, including the scan event stream.
+
+- **The transcode a seek abandons is now cached**, and a cache fill has a
+ deadline. A seek used to throw away the work it interrupted.
+- **Artist favourites survive a change of artist identity spec.**
+- **The track pid is a relocation hint**, asked only when path and content hash
+ have nothing to say, and dropped wholesale when the track spec changes — a
+ hint written under one spec must never be compared against a lookup computed
+ under another.
+- Numerous fixes across the scrobbling queue, the canvas store, the upload
+ session, the event feed's tenancy, and the web client's session handling.
+
+### Verified for this release
+
+- **The upgrade from `2.0.0-beta.0`**: the tagged binary built a database from a
+ real scan of 164 files, and the current binary opened it — **16 migrations,
+ all successful**, schema 22 → 38, catalogue and FTS5 search intact. Nothing in
+ the repository would have caught this: every integration target starts from an
+ empty database. Tracked by #223.
+- **The OpenSubsonic façade, replayed on 2026-09-16** against the whole
+ replayed set — **Symfonium 15.0.1** and **DSub 5.5.3** on an Android 17
+ emulator, **Juliet** on a physical iPhone, and **Feishin 1.15.1** on Windows
+ desktop — with every result read back from server state rather than from what
+ a client displayed. Playback and seeking on cold transcodes, catalogue
+ browsing, the full playlist cycle, favourites, ratings, scrobbles and the
+ server-side play queue all behave, and seeking by byte range was
+ re-established on iOS and on desktop. Substreamer is not part of the set: that
+ build no longer installs on the current Android device, and Juliet took its
+ place. See [the compatibility matrix](docs/subsonic-compatibility.md).
+
+### Known issues
+
+The four the client campaigns found were all present in `2.0.0-beta.0` as well,
+and all four were fixed before this tag rather than carried into it: they are
+listed above as #219, #224, #225 and #226.
+
+One deviation is knowingly left in place: **`getPlayQueue` with nothing saved
+answers a bare `playQueue`**, carrying none of the `username`, `changed` or
+`changedBy` the schema requires. The schema has no way to say "empty queue" —
+the reference omits the element — so conforming means removing it, which is a
+further wire change on the one call every client makes at startup. No client has
+been observed to mind. It is pinned by a contract test so it cannot drift
+unnoticed, and #229 carries the decision.
+
+## [2.0.0-beta.0] — 2026-08-23
+
+First beta of WaveFlow Server v2: one axum binary, SQLite as the only database,
+FFmpeg for transcoding, an OpenSubsonic façade, and a React client compiled into
+the binary. The accepted design is
+[RFC-002](docs/rfcs/RFC-002-waveflow-server-v2.md).
+
+[2.0.0-beta.1]: https://github.com/InstaZDLL/waveflow-server/releases/tag/v2.0.0-beta.1
+[2.0.0-beta.0]: https://github.com/InstaZDLL/waveflow-server/releases/tag/v2.0.0-beta.0
diff --git a/Cargo.lock b/Cargo.lock
index 4b675e2f..9ae239f3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3455,7 +3455,7 @@ dependencies = [
[[package]]
name = "waveflow-server"
-version = "2.0.0-beta.0"
+version = "2.0.0-beta.1"
dependencies = [
"anyhow",
"argon2",
diff --git a/Cargo.toml b/Cargo.toml
index fa3759cc..5ea2826e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "waveflow-server"
-version = "2.0.0-beta.0"
+version = "2.0.0-beta.1"
description = "Self-hosted music server with WaveFlow and Subsonic compatibility."
authors = ["InstaZDLL
-
+
@@ -19,7 +19,7 @@ WaveFlow Server streams the music you already own to the clients you already use
No PostgreSQL. No Redis. No identity provider. No container orchestration. **One binary, one database file, one key file.**
-> **Status — `2.0.0-beta.0`.** All six milestones pass their release gates, and the OpenSubsonic façade has been replayed against four real clients on real devices — Symfonium, DSub, Feishin and Juliet — with every result read back from server state rather than from what the client displayed. See [the compatibility matrix](docs/subsonic-compatibility.md) for what each one actually exercises, and what it does not.
+> **Status — `2.0.0-beta.1`.** All six milestones pass their release gates, and the OpenSubsonic façade has been replayed against four real clients — **Symfonium 15.0.1** and **DSub 5.5.3** on an Android 17 emulator, **Feishin 1.15.1** on Windows desktop, and **Juliet** on a physical iPhone — with every result read back from server state rather than from what the client displayed. See [the compatibility matrix](docs/subsonic-compatibility.md) for what each one actually exercises, and what it does not.
## Install
@@ -32,7 +32,7 @@ docker run -d --name waveflow \
-p 4533:4533 \
-v waveflow-data:/data \
-v /path/to/music:/music:ro \
- ghcr.io/instazdll/waveflow-server:2.0.0-beta.0
+ ghcr.io/instazdll/waveflow-server:2.0.0-beta.1
```
Or with Compose — set `WAVEFLOW_MUSIC_PATH` to your music directory, which is mounted read-only:
@@ -116,6 +116,7 @@ Point any Subsonic client at `http://your-host:4533` with that username and pass
| **Run it** | [Operations](docs/operations.md) — backup, losing or rotating the instance key, reverse proxies, probes and logging |
| **Integrate** | [Native API v2](docs/api-v2-guide.md) · [Subsonic / OpenSubsonic](docs/subsonic-api-guide.md) |
| **What clients do** | [Compatibility matrix](docs/subsonic-compatibility.md) — the four replayed above, plus Substreamer as a historical row: that build no longer installs on a current device, so it is not counted toward a tag · [gap analysis](docs/opensubsonic-gap-analysis.md) |
+| **What changed** | [CHANGELOG](CHANGELOG.md) |
| **The design** | [RFC-002, the accepted design](docs/rfcs/RFC-002-waveflow-server-v2.md) · [RFC-003, sync](docs/rfcs/RFC-003-waveflow-sync-v2.md) · [RFC-004, local/server reconciliation](docs/rfcs/RFC-004-local-server-reconciliation.md) · [RFC-007, library events](docs/rfcs/RFC-007-library-event-stream.md) · [RFC-008, uploads](docs/rfcs/RFC-008-receiving-a-file.md) · [RFC-009, canvas](docs/rfcs/RFC-009-track-canvas.md) · [RFC-010, scrobbling](docs/rfcs/RFC-010-external-scrobbling.md) |
| **On a running server** | [`/reference`](http://127.0.0.1:4533/reference) for the interactive API · [`/openapi.json`](http://127.0.0.1:4533/openapi.json) for the contract |
| **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) · [CLAUDE.md](CLAUDE.md) for the conventions in depth |
diff --git a/docker-compose.yml b/docker-compose.yml
index 19bb8438..d8fec391 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,7 @@
# which is what a contributor wants and what an operator does not.
services:
waveflow:
- image: ghcr.io/instazdll/waveflow-server:2.0.0-beta.0
+ image: ghcr.io/instazdll/waveflow-server:2.0.0-beta.1
# build: .
restart: unless-stopped
ports:
diff --git a/src/lib.rs b/src/lib.rs
index aaba035e..e29158bf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -103,7 +103,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
#[openapi(
info(
title = "WaveFlow Server API",
- version = "2.0.0-beta.0",
+ version = env!("CARGO_PKG_VERSION"),
description = "Self-hosted WaveFlow music server v2.",
license(name = "AGPL-3.0-only")
),
diff --git a/tests/subsonic_contract.rs b/tests/subsonic_contract.rs
index 4d47c782..042e06b5 100644
--- a/tests/subsonic_contract.rs
+++ b/tests/subsonic_contract.rs
@@ -1652,7 +1652,7 @@ async fn play_queue_and_now_playing_name_their_children_entry() {
// schema has no way to say "empty queue" — the reference omits the element
// entirely — so conforming would mean a *third* observable change, on the
// one call every client makes at startup, with no client harm observed and
- // none of it asked for. Pinned rather than corrected, and recorded on #225.
+ // none of it asked for. Pinned rather than corrected, and carried by #229.
let unsaved = subsonic_json(&router, "getPlayQueue", "wfsk_queue", "").await;
let unsaved = &unsaved["subsonic-response"]["playQueue"];
assert!(unsaved.is_object(), "{unsaved}");