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
167 changes: 167 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <github.105mh@8shield.net>"]
edition = "2021"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</p>

<p align="center">
<img src="https://img.shields.io/static/v1?label=version&message=2.0.0-beta.0&color=emerald&style=flat-square" alt="Version" />
<img src="https://img.shields.io/static/v1?label=version&message=2.0.0-beta.1&color=emerald&style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/rust-1.94%2B-orange?style=flat-square&logo=rust" alt="Rust 1.94+" />
<img src="https://img.shields.io/badge/database-SQLite%20only-003b57?style=flat-square&logo=sqlite&logoColor=white" alt="SQLite only" />
<img src="https://img.shields.io/badge/API-OpenSubsonic-6a4bd6?style=flat-square" alt="OpenSubsonic" />
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
Expand Down
2 changes: 1 addition & 1 deletion tests/subsonic_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
Loading