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
8 changes: 4 additions & 4 deletions docs/rfcs/RFC-002-waveflow-server-v2.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions docs/subsonic-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,26 @@ the request; media items gained `albumArtists[]` and `displayAlbumArtist`, and
albums gained `artists[]` and `genres[]`; and `getAlbum` returns an album in
sleeve order rather than alphabetically.

Two wire changes land after that replay, both additive. `album` and `artist`
gained `sortName`, emitted with its default like every other supported
OpenSubsonic addition, so an album whose files carry no `ALBUMSORT` reports it
empty rather than omitting it — the difference between unknown and
unsupported. And the folder level of `getMusicDirectory` now lists the tracks
of that library which belong to no album, alongside its artists: those tracks
already named the library as their `parent`, and browsing there used to find
none of them. No existing attribute changed value, so a client validated
against the 2026-08-19 replay sees the same catalogue with two more fields on
it and one directory that is no longer a dead end.

One deviation is worth stating rather than leaving to be discovered. The
`artists[]` and `albumArtists[]` arrays on media items and albums are typed as
`ArtistID3` but carry only `id` and `name`. They are references: no
`musicBrainzId`, no `albumCount`, no `starred`, and — since sort names landed —
no `sortName` either. A client reading an artist's own fields out of one of
those entries finds them missing and should fetch the artist by the identifier
the entry carries. The artist and album entries `getMusicDirectory` returns as
`child` are a different shape and do carry those fields, `musicBrainzId`
excepted; the two projections are pinned against each other in the test suite,
in JSON and in XML.

Browser-hosted clients need their exact origins in the comma-separated `WAVEFLOW_ALLOWED_ORIGINS` setting. The server permits GET, form POST and OPTIONS from those origins and exposes the byte-range response headers used by web audio players. Wildcard origins are deliberately unsupported.
12 changes: 12 additions & 0 deletions migrations-v2/20260819000000_oauth_authorization_scopes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Carry the issuing credential's scopes onto the grant.
--
-- Until now nothing on an authorization recorded what asked for it, so the
-- session redeemed from it carried the account's whole authority whatever the
-- credential that minted it. `Access::Unrestricted` closed that path at the
-- one route that mints, which is a local property; this makes it structural.
--
-- The default is the empty list, which is exactly what every grant written
-- before this migration meant: unrestricted, because sessions were the only
-- thing that could reach the authorize route and a session is unscoped.
ALTER TABLE oauth_authorization
ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(scopes_json));
11 changes: 11 additions & 0 deletions migrations-v2/20260819010000_session_scopes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- The other half of carrying scopes through the grant.
--
-- A session is no longer unscoped by construction: one redeemed from a
-- narrowed authorization inherits that narrowing, so `authenticate` reads the
-- limit off the session the same way it already reads it off an API token.
--
-- Existing rows default to the empty list — a session issued before this
-- migration came from a password login or from a grant that could only have
-- been made by an unscoped credential, so unrestricted is what they were.
ALTER TABLE session
ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(scopes_json));
12 changes: 12 additions & 0 deletions migrations-v2/20260819020000_album_artist_sort_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- `sortName` for AlbumID3 and ArtistID3.
--
-- The scanner already reads the track's own sort title; the album's and the
-- artist's had nowhere to go, so both fields were absent — which under the
-- presence rule said "not supported" rather than "unknown". Now they can say
-- the true thing.
--
-- Added empty and filled by the next scan, like every other tag column: an
-- instance that never rescans reports the field supported and unset rather
-- than reporting something wrong.
ALTER TABLE album ADD COLUMN sort_name TEXT;
ALTER TABLE artist ADD COLUMN sort_name TEXT;
17 changes: 17 additions & 0 deletions migrations-v2/20260820000000_track_sort_tags.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Where the sort tags live so a scan can re-derive from them.
--
-- `album.sort_name` and `artist.sort_name` were written straight from whichever
-- track happened to be applied, under a COALESCE so a sibling file carrying no
-- tag would not erase what another supplied. That preserved a value the files
-- no longer carry: remove ALBUMSORT and rescan, and the old sort name stayed.
--
-- `consolidate_musicbrainz_ids` already answered this question the other way
-- for the identifiers — "a tag removed from the files has to disappear from the
-- catalogue too" — and it can only do that because the identifiers live on the
-- track. The sort tags now do the same.
--
-- The credit's sort form goes on `track_artist` rather than on `track`: a
-- joined ARTISTSORT is paired with its joined ARTIST position by position, and
-- that pairing is done where the names are split, not in SQL.
ALTER TABLE track ADD COLUMN sort_album TEXT;
ALTER TABLE track_artist ADD COLUMN sort_name TEXT;
24 changes: 16 additions & 8 deletions src/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ pub struct AuthUser {
pub id: Uuid,
pub username: String,
pub role: AccountRole,
/// The scopes limiting this request, from the API token it arrived on.
/// The scopes limiting this request, from the credential it arrived on —
/// an API token, or a session issued under a grant that carried some.
///
/// Empty means unrestricted: that is a session, an OAuth grant, or a
/// token issued without any, all of which carry the account's full
/// authority. A non-empty list is a restriction, and a route that needs
/// Empty means unrestricted: a password login, or a token issued without
/// any, both of which carry the account's full authority. A non-empty list is a restriction, and a route that needs
/// more than the list grants must refuse rather than trust the account
/// behind it. A token is handed to a script, and the point of writing
/// scopes on it is that they hold.
Expand Down Expand Up @@ -119,6 +119,8 @@ impl AuthService {
account.role,
device_id,
now_ms,
// A password login is the account itself: nothing narrows it.
&[],
)
.await
}
Expand All @@ -132,6 +134,7 @@ impl AuthService {
&self,
user_id: Uuid,
device_name: &str,
scopes: &[String],
) -> Result<AuthTokens, AuthError> {
let device_name = device_name.trim();
if device_name.is_empty() || device_name.len() > 120 {
Expand All @@ -158,6 +161,7 @@ impl AuthService {
account.role,
device_id,
now_ms,
scopes,
)
.await
}
Expand Down Expand Up @@ -204,8 +208,9 @@ impl AuthService {
id: session.user_id,
username: session.username,
role: session.role,
// A session carries the account's full authority.
scopes: Vec::new(),
// Rotation must not widen: the refreshed session answers to
// exactly the scopes the original was issued under.
scopes: session.scopes,
},
device_id: session.device_id,
})
Expand Down Expand Up @@ -254,6 +259,7 @@ impl AuthService {
role: AccountRole,
device_id: Uuid,
now_ms: i64,
scopes: &[String],
) -> Result<AuthTokens, AuthError> {
let access_token = security::generate_token("wfa_");
let refresh_token = security::generate_token("wfr_");
Expand All @@ -271,6 +277,7 @@ impl AuthService {
access_expires_at,
refresh_expires_at,
now_ms,
scopes,
})
.await
.map_err(db_unavailable)?;
Expand All @@ -284,8 +291,9 @@ impl AuthService {
id: user_id,
username,
role,
// A session carries the account's full authority.
scopes: Vec::new(),
// Whatever the credential that issued it carried — empty for a
// password login, narrowed for a grant made by a scoped token.
scopes: scopes.to_vec(),
},
device_id,
})
Expand Down
115 changes: 108 additions & 7 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ pub struct CatalogTrackInput {
pub replay_gain_album_peak: Option<f64>,
pub bpm: Option<i64>,
pub sort_title: Option<String>,
/// Sort forms of the album title and of the two artist credits, as
/// tagged. Multi-valued credits carry them joined by the same separator.
pub sort_album: Option<String>,
pub sort_album_artist: Option<String>,
pub sort_artist: Option<String>,
pub comment: Option<String>,
/// Multi-valued, split like `artist` and `genre`.
pub isrc: Option<String>,
Expand Down Expand Up @@ -381,6 +386,58 @@ impl Database {
/// Albums and artists whose tracks carry no identifier are set back to
/// `NULL` rather than left alone: this runs after every scan, so a tag
/// removed from the files has to disappear from the catalogue too.
/// Re-derives `album.sort_name` and `artist.sort_name` from the tags the
/// library's available tracks still carry.
///
/// Sibling of [`Self::consolidate_musicbrainz_ids`] and run beside it, for
/// the same reason and by the same rule: a value derived from tags has to
/// follow the tags, including when they go away. Writing the sort name
/// during the per-track upsert could not do that — the row is rewritten
/// once per track of every album the artist appears on, so a file carrying
/// no tag had to be prevented from erasing what a sibling supplied, and
/// that preservation outlived the tag itself.
///
/// The majority wins, ties broken deterministically, so two scans of
/// unchanged files answer the same thing. An album takes the sort title
/// its tracks agree on; an artist takes the sort form its credits agree
/// on, from `track_artist` where the joined tag was already paired with
/// the joined credit. An album artist credited on none of the tracks
/// therefore has no sort form of its own — the catalogue holds no tag
/// that is about it alone.
pub async fn consolidate_sort_names(&self, library_id: Uuid) -> Result<(), sqlx::Error> {
let _writer = self.writer_guard().await;
let mut tx = self.pool().begin().await?;
sqlx::query(
"UPDATE album SET sort_name = ( \
SELECT t.sort_album FROM track t \
WHERE t.album_id = album.id AND t.is_available = 1 \
AND t.sort_album IS NOT NULL \
GROUP BY t.sort_album \
ORDER BY COUNT(*) DESC, t.sort_album \
LIMIT 1) \
WHERE library_id = ?",
)
.bind(library_id.to_string())
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE artist SET sort_name = ( \
SELECT ta.sort_name FROM track_artist ta \
JOIN track t ON t.id = ta.track_id \
WHERE ta.artist_id = artist.id AND t.is_available = 1 \
AND ta.sort_name IS NOT NULL \
GROUP BY ta.sort_name \
ORDER BY COUNT(*) DESC, ta.sort_name \
LIMIT 1) \
WHERE library_id = ?",
)
.bind(library_id.to_string())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}

pub async fn consolidate_musicbrainz_ids(&self, library_id: Uuid) -> Result<(), sqlx::Error> {
let _writer = self.writer_guard().await;
let mut tx = self.pool().begin().await?;
Expand Down Expand Up @@ -700,6 +757,30 @@ impl Database {
let id = upsert_artist(tx, library_id, name, now).await?;
album_artist_id.get_or_insert(id);
}
// The sort form of each credit *on this track*, which is where a
// joined ARTISTSORT can still be paired with its joined ARTIST. A
// credit the track tag says nothing about falls back to the album
// artist tag when the two name the same artist — ALBUMARTISTSORT is
// the more commonly written of the two, and matching on the name
// rather than on position means this cannot file one artist under
// another's sort form.
let credit_sorts = {
let track_sorts = sort_names_for(&artist_names, input.sort_artist.as_deref());
let album_sorts =
sort_names_for(&album_artist_names, input.sort_album_artist.as_deref());
artist_names
.iter()
.zip(track_sorts)
.map(|(name, sort)| {
sort.or_else(|| {
album_artist_names
.iter()
.position(|candidate| candidate == name)
.and_then(|at| album_sorts.get(at).cloned().flatten())
})
})
.collect::<Vec<_>>()
};
let album_id = upsert_album(
tx,
library_id,
Expand All @@ -722,11 +803,12 @@ impl Database {
year, track_number, disc_number, duration_ms, bitrate, sample_rate, channels, bit_depth, \
codec, musical_key, tag_rating, musicbrainz_recording_id, musicbrainz_release_id, \
musicbrainz_artist_id, replay_gain_track_gain, replay_gain_track_peak, \
replay_gain_album_gain, replay_gain_album_peak, bpm, sort_title, comment, isrc, \
replay_gain_album_gain, replay_gain_album_peak, bpm, sort_title, sort_album, \
comment, isrc, \
moods, explicit_status, \
lyrics_hash, is_available, last_seen_scan_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
1, ?, ?, ?) \
ON CONFLICT (id) DO UPDATE SET album_id=excluded.album_id, artwork_hash=excluded.artwork_hash, \
relative_path=excluded.relative_path, file_size=excluded.file_size, \
Expand All @@ -744,7 +826,8 @@ impl Database {
replay_gain_track_peak=excluded.replay_gain_track_peak, \
replay_gain_album_gain=excluded.replay_gain_album_gain, \
replay_gain_album_peak=excluded.replay_gain_album_peak, \
bpm=excluded.bpm, sort_title=excluded.sort_title, comment=excluded.comment, \
bpm=excluded.bpm, sort_title=excluded.sort_title, sort_album=excluded.sort_album, \
comment=excluded.comment, \
isrc=excluded.isrc, moods=excluded.moods, \
explicit_status=excluded.explicit_status, \
lyrics_hash=excluded.lyrics_hash, is_available=1, last_seen_scan_id=excluded.last_seen_scan_id, \
Expand All @@ -763,7 +846,8 @@ impl Database {
.bind(input.musicbrainz_artist_id.as_deref())
.bind(input.replay_gain_track_gain).bind(input.replay_gain_track_peak)
.bind(input.replay_gain_album_gain).bind(input.replay_gain_album_peak)
.bind(input.bpm).bind(input.sort_title.as_deref()).bind(input.comment.as_deref())
.bind(input.bpm).bind(input.sort_title.as_deref()).bind(input.sort_album.as_deref())
.bind(input.comment.as_deref())
.bind(input.isrc.as_deref())
.bind(input.moods.as_deref()).bind(input.explicit_status.as_deref())
.bind(&input.lyrics_hash)
Expand Down Expand Up @@ -796,9 +880,9 @@ impl Database {
.execute(&mut **tx)
.await?;
for (position, artist_id) in artist_ids.iter().enumerate() {
sqlx::query("INSERT INTO track_artist (track_id, artist_id, library_id, position) VALUES (?, ?, ?, ?)")
sqlx::query("INSERT INTO track_artist (track_id, artist_id, library_id, position, sort_name) VALUES (?, ?, ?, ?, ?)")
.bind(track_id.to_string()).bind(artist_id.to_string()).bind(library_id.to_string())
.bind(position as i64).execute(&mut **tx).await?;
.bind(position as i64).bind(credit_sorts.get(position).and_then(Option::as_deref)).execute(&mut **tx).await?;
}
sqlx::query("DELETE FROM track_genre WHERE track_id = ?")
.bind(track_id.to_string())
Expand Down Expand Up @@ -1019,6 +1103,22 @@ async fn upsert_artist(
parse_uuid(value)
}

/// Pairs a split credit with its split sort form.
///
/// `ARTISTSORT` carries the same separator as `ARTIST` when a credit is
/// joined, so the two lists line up position by position — that is the
/// convention taggers write. When they do not line up, the tag is describing
/// something this cannot map, and guessing would file an artist under another
/// artist's name: every credit then gets no sort form rather than a wrong one.
fn sort_names_for(names: &[String], raw_sort: Option<&str>) -> Vec<Option<String>> {
let sorts = split_values(raw_sort);
if sorts.len() == names.len() {
sorts.into_iter().map(Some).collect()
} else {
vec![None; names.len()]
}
}

async fn upsert_genre(
tx: &mut Transaction<'_, Sqlite>,
library: Uuid,
Expand Down Expand Up @@ -1052,7 +1152,8 @@ async fn upsert_album(
let id = Uuid::new_v4();
let value: String = sqlx::query_scalar("INSERT INTO album (id, library_id, title, canonical_title, identity_key, album_artist_id, album_artist_name, is_compilation, year, artwork_hash, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (library_id, identity_key) DO UPDATE SET title=excluded.title, album_artist_name=excluded.album_artist_name, \
is_compilation=excluded.is_compilation, year=COALESCE(excluded.year, album.year), artwork_hash=COALESCE(excluded.artwork_hash, album.artwork_hash), updated_at=excluded.updated_at RETURNING id")
is_compilation=excluded.is_compilation, year=COALESCE(excluded.year, album.year), artwork_hash=COALESCE(excluded.artwork_hash, album.artwork_hash), \
updated_at=excluded.updated_at RETURNING id")
.bind(id.to_string()).bind(library.to_string()).bind(title.trim()).bind(canonical).bind(identity)
.bind(album_artist_id.map(|id| id.to_string())).bind(album_artist).bind(i64::from(input.is_compilation))
.bind(input.year).bind(artwork).bind(now).bind(now).fetch_one(&mut **tx).await?;
Expand Down
Loading
Loading