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
42 changes: 40 additions & 2 deletions docs/api-v2-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,10 +777,25 @@ deployment setting and never a member's, so a client never sends one.
{ "provider": "maloja", "destination": "alice", "available": true },
{ "provider": "maloja", "destination": "bob", "available": true },
{ "provider": "lastfm", "destination": "default", "available": false,
"unavailable": "last.fm needs WAVEFLOW_PUBLIC_URL to be an https address for the browser journey; an operator can still link an account from this server's command line" }
"unavailable": "browser_journey_needs_https" }
]
```

`unavailable` is a **case, not a sentence**, and it is present only when
`available` is `false`. It was finished English prose before 2026-09-15,
which every client printed verbatim — including one that ships in two
languages, so a French reader was told in English what to change in a
configuration file. The wording belongs to whoever is doing the telling. Two
cases, and both name something an operator can change:

| Case | What is missing |
| --- | --- |
| `no_application_configured` | `WAVEFLOW_SCROBBLE_LASTFM_API_KEY` and `_SECRET` are unset, so there is no Last.fm application to speak for. |
| `browser_journey_needs_https` | `WAVEFLOW_PUBLIC_URL` is not an `https` address, so there is nowhere to bring a browser back to. The command-line journey below needs no public address and still works. |

Treat an unrecognised case as "this server did not say why" rather than as a
blank: a server is free to be newer than the client reading it.

Several instances of one recipient are ordinary — Maloja self-hosts, and on a
household server everybody has their own. Addresses are never published: a
member does not need one, and no link stores one.
Expand Down Expand Up @@ -948,7 +963,30 @@ unaffected.

Creating a library starts its first scan and returns both `library_id` and
`scan_id`. The scan event route uses Server-Sent Events and still requires the
Bearer token.
Bearer token — so `EventSource`, which sends no headers, cannot open it; read
the response body of a `fetch` instead.

**Both of its frames carry the same object**, the one
`GET /api/v2/scans/{scan_id}` answers: an opening `snapshot` event, then a
`progress` event per step. A watcher replaces what it holds and never has to
tell the two apart.

```text
event: snapshot
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":0,
"added":0,"updated":0,"moved":0,"skipped":0,"unavailable":0,"errors":0,
"current_path":null,"message":null}

event: progress
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":61, …}
```

Builds before 2026-09-15 sent a different object for `progress`, naming
those same three fields `scan_id`, `total` and `processed` while the ten
counters beside them kept their names. A client bound to the snapshot therefore
showed its totals as absent from the first progress frame on, and its
per-outcome counters correctly — which reads as a display bug rather than as
two shapes on one stream. There is one shape now.

`GET /api/v2/libraries` answers, for each library the account belongs to, its
`role` and `accepts_uploads`. The flag says whether the library takes files at
Expand Down
6 changes: 5 additions & 1 deletion src/api/libraries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,16 @@ pub async fn scan_events(
.map_err(db_error)?
.ok_or(ApiError::NotFound)?;
let mut receiver = state.scanner.subscribe(scan_id);
// Both events carry a `ScanJobRecord`, so a watcher replaces what it holds
// and never has to tell the two frames apart. `progress` used to carry the
// scanner's own `ScanProgress`, which names three of the same fields
// differently — see `ScanProgress::record`.
let output = async_stream::stream! {
yield Ok(Event::default().event("snapshot").json_data(initial).expect("scan snapshot serializes"));
if let Some(ref mut receiver) = receiver {
loop {
match receiver.recv().await {
Ok(progress) => yield Ok(Event::default().event("progress").json_data(progress).expect("scan progress serializes")),
Ok(progress) => yield Ok(Event::default().event("progress").json_data(progress.record()).expect("scan progress serializes")),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::ScrobbleProvider,
services::ScrobbleLinkState,
services::ScrobbleDestinationName,
services::ScrobbleUnavailable,
api::LastFmAuthorizationResponse,
services::UncertainScrobble,
api::LinkScrobbleRequest,
Expand Down Expand Up @@ -289,8 +290,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
sync::SyncPage,
media::StreamTicketResponse,
media::CanvasResponse,
api::LibraryEventAckRequest,
scanner::ScanProgress
api::LibraryEventAckRequest
)),
modifiers(&SecurityAddon),
tags(
Expand Down
40 changes: 36 additions & 4 deletions src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,31 @@ use lofty::{
file::TaggedFileExt,
prelude::{Accessor, AudioFile, ItemKey},
};
use serde::Serialize;
use tokio::sync::{broadcast, Mutex};
use utoipa::ToSchema;
use uuid::Uuid;
use walkdir::WalkDir;

use crate::{
catalog::{ApplyOutcome, ArtworkInput, CatalogApply, CatalogTrackInput, LibraryRecord},
catalog::{
ApplyOutcome, ArtworkInput, CatalogApply, CatalogTrackInput, LibraryRecord, ScanJobRecord,
},
database::Database,
lyrics::{self, LyricsInput},
};

const MAX_LYRICS_BYTES: u64 = 1024 * 1024;

#[derive(Debug, Clone, Serialize, ToSchema)]
/// One reading of a scan in flight, as it travels between the scanning task and
/// whoever is watching.
///
/// Deliberately **not** `Serialize`. It used to be, and `/api/v2/scans/{id}/events`
/// yielded it verbatim for every `progress` event while the opening `snapshot`
/// carried a [`ScanJobRecord`] — two shapes of the same reading on one stream,
/// disagreeing on three names out of thirteen. A watcher that bound `total_files`
/// showed `undefined` from the first progress frame on. The stream now converts
/// through [`ScanProgress::record`], and dropping the derive is what stops the
/// other shape coming back: there is no way to put this type on a wire.
#[derive(Debug, Clone)]
pub struct ScanProgress {
pub scan_id: Uuid,
pub library_id: Uuid,
Expand All @@ -46,6 +56,28 @@ pub struct ScanProgress {
pub message: Option<String>,
}

impl ScanProgress {
/// The same reading in the shape `GET /api/v2/scans/{id}` answers, which is
/// the one shape the event stream speaks.
pub fn record(&self) -> ScanJobRecord {
ScanJobRecord {
id: self.scan_id,
library_id: self.library_id,
status: self.status.clone(),
total_files: self.total as i64,
processed_files: self.processed as i64,
added: self.added,
updated: self.updated,
moved: self.moved,
skipped: self.skipped,
unavailable: self.unavailable,
errors: self.errors,
current_path: self.current_path.clone(),
message: self.message.clone(),
}
}
}

#[derive(Clone)]
pub struct ScanManager {
db: Database,
Expand Down
14 changes: 6 additions & 8 deletions src/services/lastfm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,16 @@ impl LastFmJourney {
}
}

/// Why Last.fm cannot be linked on this server, in words an operator can act
/// on.
/// Why Last.fm cannot be linked on this server.
///
/// Published beside the destination rather than discovered when somebody tries:
/// a link that fails later without explanation is the silent failure this whole
/// RFC spends itself preventing.
pub(super) fn lastfm_unavailability(config: &crate::config::Config) -> Option<&'static str> {
pub(super) fn lastfm_unavailability(
config: &crate::config::Config,
) -> Option<super::ScrobbleUnavailable> {
if config.lastfm.is_none() {
return Some("no last.fm application is configured on this server");
return Some(super::ScrobbleUnavailable::NoApplicationConfigured);
}
if !crate::api::public_url_is_https(config.public_url.as_deref()) {
// Naming the other way out, since 2026-09-15. This said last.fm
Expand All @@ -101,10 +102,7 @@ pub(super) fn lastfm_unavailability(config: &crate::config::Config) -> Option<&'
// all — it is true of *this* journey. A reason an operator can act on
// has to name the action that still exists, or it reads as a dead end
// where there is a door.
return Some(
"last.fm needs WAVEFLOW_PUBLIC_URL to be an https address for the browser journey; \
an operator can still link an account from this server's command line",
);
return Some(super::ScrobbleUnavailable::BrowserJourneyNeedsHttps);
}
None
}
Expand Down
6 changes: 3 additions & 3 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,9 +995,9 @@ pub struct DomainServices {
/// What one Last.fm journey needs beyond an account and a destination, or
/// `None` when this server cannot carry one at all.
lastfm_journey: Option<lastfm::LastFmJourney>,
/// Why not, in words an operator can act on. Published beside the
/// Why not, as a code the caller words. Published beside the
/// destination rather than discovered when somebody tries to link.
lastfm_unavailable: Option<&'static str>,
lastfm_unavailable: Option<ScrobbleUnavailable>,
/// How a request token becomes a session key, by destination name. Filled
/// after construction like [`Self::register_scrobble_target`], and for the
/// same reason.
Expand Down Expand Up @@ -1077,7 +1077,7 @@ pub use lastfm::{
};
pub use scrobbling::{
ScrobbleDestinationName, ScrobbleDrain, ScrobbleEnvelope, ScrobbleLinkState, ScrobbleProvider,
ScrobbleTarget, ScrobbleVerdict, UncertainScrobble,
ScrobbleTarget, ScrobbleUnavailable, ScrobbleVerdict, UncertainScrobble,
};

impl DomainServices {
Expand Down
24 changes: 22 additions & 2 deletions src/services/scrobbling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,35 @@ pub struct ScrobbleDestinationName {
pub destination: String,
/// Whether this server can actually link it right now.
pub available: bool,
/// And when it cannot, why — in words an operator can act on.
/// And when it cannot, why.
///
/// Published here rather than left to be discovered: a link that fails
/// later with no explanation is the silent failure this whole RFC spends
/// itself preventing. Last.fm is the one recipient that can be declared and
/// still unusable, because its journey needs an application the operator
/// registered and an `https` address to bring a person back to.
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<&'static str>,
pub unavailable: Option<ScrobbleUnavailable>,
}

/// Why a declared instance cannot be linked — a code, not a sentence.
///
/// This was an English sentence until 2026-09-15, printed verbatim into a web
/// client that ships in two languages: a French reader was told, in English,
/// what to change in their configuration. A reason only helps whoever can read
/// it, so what crosses the wire is the case and the wording belongs to whoever
/// is doing the telling. The two cases are the two things an operator can
/// actually change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScrobbleUnavailable {
/// No Last.fm application is declared on this server —
/// `WAVEFLOW_SCROBBLE_LASTFM_API_KEY` and `_SECRET`.
NoApplicationConfigured,
/// `WAVEFLOW_PUBLIC_URL` is not an `https` address, so there is nowhere to
/// bring a browser back to. The command-line journey needs no public
/// address at all and still works.
BrowserJourneyNeedsHttps,
}

/// What one link's queue looks like from outside: counters, never content.
Expand Down
88 changes: 88 additions & 0 deletions tests/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,3 +921,91 @@ async fn an_unreferenced_cover_and_its_thumbnails_go_once_they_are_old_enough()
"the dead link is reported, not repaired"
);
}

/// The event stream speaks one shape, and every counter lands where it belongs.
///
/// `/api/v2/scans/{id}/events` opens with a `snapshot` carrying a
/// `ScanJobRecord` and then sends a `progress` per step. Those frames used to
/// be two different types: `progress` carried the scanner's own `ScanProgress`,
/// which names `id`, `total_files` and `processed_files` as `scan_id`, `total`
/// and `processed`. A watcher bound to the snapshot read `undefined` from the
/// first progress frame on, while the ten counters that happen to share a name
/// kept working — which is why it read as a display bug rather than a shape.
///
/// That the frames agree is now a compile-time fact: `ScanProgress` is no
/// longer `Serialize`, so the handler cannot yield it and has to convert. What
/// the compiler cannot check is that the conversion puts each number where it
/// belongs — eleven of the thirteen fields are `i64` or `Uuid`, so a transposed
/// pair type-checks perfectly. That is what this pins.
#[test]
fn scan_progress_converts_into_the_shape_the_stream_sends() {
let scan = uuid::Uuid::new_v4();
let library = uuid::Uuid::new_v4();
let record = waveflow_server::scanner::ScanProgress {
scan_id: scan,
library_id: library,
status: "running".into(),
total: 97,
processed: 61,
added: 1,
updated: 2,
moved: 3,
skipped: 4,
unavailable: 5,
errors: 6,
current_path: Some("Rue Delacour/Nocturne.flac".into()),
message: Some("still reading".into()),
}
.record();

// Distinct values throughout, so a swap between any two shows up here
// rather than in a client six months later.
assert_eq!(
record.id, scan,
"the scan's own id, under the snapshot's name"
);
assert_eq!(record.library_id, library);
assert_eq!(record.status, "running");
assert_eq!(record.total_files, 97);
assert_eq!(record.processed_files, 61);
assert_eq!(record.added, 1);
assert_eq!(record.updated, 2);
assert_eq!(record.moved, 3);
assert_eq!(record.skipped, 4);
assert_eq!(record.unavailable, 5);
assert_eq!(record.errors, 6);
assert_eq!(
record.current_path.as_deref(),
Some("Rue Delacour/Nocturne.flac")
);
assert_eq!(record.message.as_deref(), Some("still reading"));

// And the names themselves, since they are the whole point: a client binds
// these thirteen and nothing else.
let json = serde_json::to_value(&record).unwrap();
let mut keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"added",
"current_path",
"errors",
"id",
"library_id",
"message",
"moved",
"processed_files",
"skipped",
"status",
"total_files",
"unavailable",
"updated",
]
);
}
13 changes: 10 additions & 3 deletions tests/scrobbling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ use waveflow_server::catalog::LibraryRecord;
use waveflow_server::config::ScrobbleLimits;
use waveflow_server::database::LibraryVisibility;
use waveflow_server::services::{
ScrobbleEnvelope, ScrobbleProvider, ScrobbleTarget, ScrobbleVerdict, ServiceError,
ScrobbleEnvelope, ScrobbleProvider, ScrobbleTarget, ScrobbleUnavailable, ScrobbleVerdict,
ServiceError,
};
use waveflow_server::AppState;

Expand Down Expand Up @@ -3821,7 +3822,10 @@ async fn last_fm_says_why_it_is_unavailable_instead_of_failing_later() {
let declared = state.services.scrobble_destinations();
assert_eq!(declared.len(), 1);
assert!(!declared[0].available);
assert!(declared[0].unavailable.unwrap().contains("application"));
assert_eq!(
declared[0].unavailable,
Some(ScrobbleUnavailable::NoApplicationConfigured)
);
assert!(matches!(
state
.services
Expand All @@ -3838,7 +3842,10 @@ async fn last_fm_says_why_it_is_unavailable_instead_of_failing_later() {
let state = waveflow_server::initialize(&plaintext).await.unwrap();
let declared = state.services.scrobble_destinations();
assert!(!declared[0].available);
assert!(declared[0].unavailable.unwrap().contains("https"));
assert_eq!(
declared[0].unavailable,
Some(ScrobbleUnavailable::BrowserJourneyNeedsHttps)
);
assert!(matches!(
state
.services
Expand Down
Loading
Loading