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
22 changes: 21 additions & 1 deletion docs/api-v2-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,32 @@ curl "https://music.example.com/api/v2/artists/ARTIST_UUID/top-songs?limit=10" \
every other genre filter, and both are the native form of a Subsonic method
(`getSongsByGenre`, `getRandomSongs`) resolving through the same service.

`GET /api/v2/artists/{artist_id}` carries an `enrichment` object: what metadata
providers said about the artist, when an operator switched enrichment on
([RFC-011](rfcs/RFC-011-metadata-enrichment.md)). It is always present, and its
`state` says which of four situations holds, because absence could not:

| `state` | meaning |
| --- | --- |
| `disabled` | no provider is configured; nothing has been asked and nothing will be |
| `pending` | a provider has not answered yet; ask again shortly |
| `ready` | there is something to show |
| `none` | every provider answered, and none has anything this account can be shown |

`bio` (`summary`, `full`) and `similar` (`artists`, each an artist summary) are
`null` when there is nothing, and each carries its own `source` — `provider`
and `fetched_at` — because the fields are merged across providers and may come
from different ones. `similar` lists only artists in libraries this account
belongs to. The page never waits on a provider: the first request schedules
the lookup and answers `pending`.

`GET /api/v2/artists/{artist_id}/top-songs` answers that artist's most played
tracks **for the calling account**, ordered by `play_count` and then by title.
It is the native form of `getTopSongs`, through the same service.

It is computed from local listening history alone — `play_event` — and not from
any remote notion of popularity, because the server queries no remote source.
any remote notion of popularity. Metadata enrichment, when an operator switches
it on, asks providers about artists, never about plays.
Three consequences worth knowing before building on it:

- **a track the account has never played is absent**, not last, so a fresh
Expand Down
2 changes: 2 additions & 0 deletions docs/rfcs/RFC-002-waveflow-server-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Genre matching is one rule across every method. `getGenres` groups by `genre.can

`getArtistInfo` and `getArtistInfo2` resolve the requested artist through tenant-scoped catalogue access and return their standard empty containers until artist biography enrichment is implemented. This preserves compatibility with clients such as DSub without fabricating biography or similar-artist metadata. `getAlbumInfo` and `getAlbumInfo2` behave the same way for albums, for the same reason: Feishin and Symfonium call them as soon as an album page opens, and an unimplemented-method error there reads to the client as a broken album rather than as absent enrichment. They carry one real value, the album's release identifier, as their `musicBrainzId` element; notes and biography images stay absent because WaveFlow queries no remote source. `AlbumInfo` predates the presence rule and its members are elements rather than attributes, so an album with no release id omits the element instead of sending it empty.

**Amended on 2026-10-15 by [RFC-011](RFC-011-metadata-enrichment.md).** "WaveFlow queries no remote source" was a decision, not an observation, and it now holds only while no metadata provider is configured — which is the default, and what an upgraded server keeps. An operator who names a provider in `WAVEFLOW_METADATA_PROVIDERS` switches on lookups about artists. `getArtistInfo` and `getArtistInfo2` then carry the cached biography and the similar artists the caller can see. The lookup is scheduled by the first visit and never waited for, so that visit still receives the empty container. Album notes are not enriched, so `getAlbumInfo` and `getAlbumInfo2` are unchanged.

API tokens are restricted by their scopes on every route, not only on the administrative ones. `Access` names what a route needs — `Read`, `Write` or `Admin` — and is chosen at the single point where the caller is resolved, so a route cannot exist without answering the question. An empty scope list is unrestricted, which is what a password login, a grant made by an unscoped credential and a token issued without scopes carry; a non-empty list grants only what it names, and a name the server does not know grants nothing, so no vocabulary has to be enumerated. `admin` implies `write`, because a credential trusted to create accounts is not usefully barred from creating a playlist. Issuing a token stays administrative on both surfaces: a token carries the authority of the account it belongs to, so who may mint one is a question about the instance.

Scopes travel through the authorization code flow, so a session is never broader than the credential that issued it. `oauth_authorization` and `session` each carry a `scopes_json` column: the consent step records the caller's scopes on the grant, redeeming it issues the session under those same scopes, and `authenticate` reads the limit off the session exactly as it already read it off an API token. Rotation carries them too — refreshing a narrowed session returns a narrowed one. Both columns default to the empty list, which is what every row written before them meant. The flaw this replaces was an escalation in two requests: the flow returned a session with the account's whole authority and neither the authorization row nor the session recorded what asked for it, so a `write` token could reach `POST /api/v2/oauth/authorize` and come back answering to `Admin`. The category error was treating "mutate user data" and "issue a reference to this account" as one authority; the answer is not to forbid the second but to make it inherit. Minting therefore asks `Write` — pairing a device is a mutation on the account — rather than the blanket restriction that stood in while the columns did not exist. An ordinary account still pairs its own devices from its own session, which is what the flow is for.
Expand Down
9 changes: 5 additions & 4 deletions docs/rfcs/RFC-011-metadata-enrichment.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
- **Implemented by**: in progress, in the order of
[the implementation plan](../metadata-enrichment-plan.md):
InstaZDLL/WaveFlow#855 (the core's clients accept a client, an address and a
key), #270 (the pin moved onto them), and the pull request closing #265 (the
cache and the engine that fills it). No surface answers from the cache yet,
so every container is still the empty one. The *Status* field above never
flips in this project; this line is the one that says what is running.
key), #270 (the pin moved onto them), #271 (the cache and the engine that
fills it), and the pull request closing #266 (`getArtistInfo`,
`getArtistInfo2` and the native artist page read it). Similar songs and
portraits are still to come. The *Status* field above never flips in this
project; this line is the one that says what is running.
- **Date**: 2026-10-14
- **Revised**: 2026-10-15, while implementing the cache (#265). Decisions 4, 6
and 10 rested on attachment points the code does not hold; each correction
Expand Down
7 changes: 4 additions & 3 deletions docs/subsonic-api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ with Subsonic error code `0`, like every other protocol failure.
|---|---|---|
| System | `ping`, `getLicense`, `getOpenSubsonicExtensions`, `tokenInfo` | Extensions are listed below. `tokenInfo` returns the username the presented credential resolves to. |
| Catalogue roots | `getMusicFolders`, `getIndexes`, `getArtists`, `getArtist`, `getAlbum`, `getSong`, `getGenres`, `getMusicDirectory` | IDs and `musicFolderId` values are UUIDs. |
| Artist and album information | `getArtistInfo`, `getArtistInfo2`, `getAlbumInfo`, `getAlbumInfo2` | Validate tenant access and return the standard empty container; enrichment is not implemented. |
| Artist information | `getArtistInfo`, `getArtistInfo2` | Validate tenant access, then answer from the metadata cache: `biography` and the `similarArtist` entries the caller can see (at most `count`, default 20). The empty container when no provider is configured, which is the default, and on the first visit to an artist, which schedules the lookup. No image URLs; `includeNotPresent` is not honoured. |
| Album information | `getAlbumInfo`, `getAlbumInfo2` | Validate tenant access and return the release identifier when the album has one; notes are not enriched. |
| Album discovery | `getAlbumList`, `getAlbumList2`, `getRandomSongs`, `getSongsByGenre` | Both legacy and ID3 album-list containers are supported. |
| Search | `search3`, `search2` | Independent artist, album and song pagination. `search2` is the same payload in the `searchResult2` container. |
| Playlists | `getPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist` | Shared with native/web user data. |
Expand Down Expand Up @@ -361,8 +362,8 @@ thing. An artist takes the identifier from the tracks it is the *first* credit
of, because the tag is one value on a file that may credit several artists.

`getAlbumInfo` and `getAlbumInfo2` carry that release identifier as their
`musicBrainzId` element. They remain otherwise empty: WaveFlow queries no remote
source, so there are no notes and no biography images. Being a classic Subsonic
`musicBrainzId` element. They remain otherwise empty: album notes are not
enriched, so there are no notes and no images. Being a classic Subsonic
response rather than an OpenSubsonic one, the element is omitted when the album
has no identifier instead of being sent empty.

Expand Down
2 changes: 1 addition & 1 deletion src/api/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ pub async fn get_artist(
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
.artist(user.id, artist_id)
.artist_page(user.id, artist_id)
.await
.map(Json)
.map_err(service_error)
Expand Down
8 changes: 8 additions & 0 deletions src/enrichment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,14 @@ impl Enrichment {
self.engine.is_some()
}

/// How many providers are configured — the number of rows a name has
/// once every one of them has answered.
pub fn provider_count(&self) -> usize {
self.engine
.as_ref()
.map_or(0, |engine| engine.sources.len())
}

/// What the cache holds about the artist called `name`, one row per
/// configured provider that has one, in the operator's order — and a fill
/// scheduled for every configured provider whose row is missing or stale.
Expand Down
11 changes: 6 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ pub struct AppState {
/// The body ceiling the canvas route carries, derived from
/// `WAVEFLOW_CANVAS_MAX_BYTES` so the two cannot disagree.
pub canvas_body_limit: usize,
/// Metadata enrichment — RFC-011. Disabled unless the operator named a
/// provider.
pub enrichment: enrichment::Enrichment,
}

/// Roughly one offer's worth of JSON — a sixty-four character hash, a size, a
Expand Down Expand Up @@ -255,6 +252,11 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::ArtistSummary,
services::AlbumDetail,
services::ArtistDetail,
services::ArtistEnrichment,
services::EnrichmentState,
services::EnrichmentSource,
services::EnrichedBio,
services::EnrichedSimilar,
services::SearchResult,
services::PlaylistItem,
services::QueueItem,
Expand Down Expand Up @@ -681,7 +683,7 @@ pub async fn initialize(config: &Config) -> anyhow::Result<AppState> {
services.reconcile_scrobble_links().await?;
// Off unless the operator named a provider, and then it still makes no
// request until somebody asks about an artist (RFC-011 decisions 3 and 6).
let enrichment = enrichment::Enrichment::new(db.clone(), &config.metadata)?;
services.use_enrichment(enrichment::Enrichment::new(db.clone(), &config.metadata)?);
Ok(AppState {
db,
auth,
Expand All @@ -700,7 +702,6 @@ pub async fn initialize(config: &Config) -> anyhow::Result<AppState> {
// Refused at startup if it does not fit, so this conversion cannot be
// the place that decides what a ceiling falls back to.
canvas_body_limit: usize::try_from(config.canvas.max_bytes).unwrap_or(usize::MAX),
enrichment,
})
}

Expand Down
Loading
Loading