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
24 changes: 22 additions & 2 deletions docs/web-client-gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,22 @@ override = la correction WaveFlow, ou absente (ligne de `track_override`)
effective = override ?? source
```

> **Rectifié le 2026-09-10, en écrivant la route de lecture.** Ce schéma vaut
> pour **sept** des neuf champs, pas pour les neuf. Les artistes et les genres
> ne sont pas des colonnes : une correction sur l'un ou l'autre est
> **matérialisée** dans `track_participant`, `track_genre` et les chaînes
> `*_display` — `apply_track_override_lists` fait `DELETE FROM
> track_participant` puis réinsère —, parce que ces lignes alimentent toutes
> les projections et l'index de recherche. **Une fois la correction posée, la
> valeur du fichier n'est donc plus en base**, seulement dans le fichier. Les
> sept champs scalaires survivent parce que la projection les fusionne par
> `COALESCE` au lieu de les écraser.
>
> Conséquence pour l'éditeur : il montre la provenance des sept, et propose sur
> les deux autres un rétablissement **sans aperçu**. Rétablir relit le fichier,
> ce que `set_track_metadata` fait déjà ; ce qui n'est pas faisable à bon
> marché, c'est de l'afficher d'avance.

Le serveur travaille déjà ainsi et le dit : la migration
`20260826010000_track_override.sql` écrit « *every column is nullable and NULL
means "no correction here, use what the file said"* », et `song_select!` fait
Expand Down Expand Up @@ -381,10 +397,14 @@ Une route de lecture nouvelle, et la route d'écriture existante corrigée.

```
GET /api/v2/tracks/{id} → SongItem, valeur effective, inchangé
GET /api/v2/tracks/{id}/overrides → { source: {…}, overrides: {…} }
PATCH /api/v2/tracks/{id} → patch partiel à trois états
GET /api/v2/tracks/{id}/overrides → { source: {…}, overrides: {…} } ✅ écrite
PATCH /api/v2/tracks/{id} → patch partiel à trois états ⏳ bloquée
```

La route de lecture est écrite : elle n'a aucun consommateur à ménager et le
prérequis d'audit ne portait que sur le **changement de sémantique**. Le
`PATCH` attend toujours l'audit du client desktop.

`overrides` doit être la **ligne réelle**, pas une seconde projection
`COALESCE` : c'est précisément la différence que l'éditeur affiche. `effective`
n'a pas à y figurer, le client tient déjà le `SongItem`.
Expand Down
20 changes: 20 additions & 0 deletions src/api/libraries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,26 @@ pub async fn library_events_ack(
Ok(StatusCode::NO_CONTENT)
}

/// Who may see a library, and in what standing.
///
/// `PUT` and `DELETE` on `/libraries/{library_id}/members/{user_id}` have
/// existed since M4 with nothing to read them back, so an interface could grant
/// and revoke without showing who already had access.
#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/members", tag = "administration", params(("library_id" = Uuid, Path)), responses((status = 200, body = [crate::services::LibraryMember]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn list_library_members(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::services::LibraryMember>>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
.library_members(user.id, library_id)
.await
.map(Json)
.map_err(service_error)
}

/// What a device says it has read.
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct LibraryEventAckRequest {
Expand Down
8 changes: 8 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ pub fn router(state: AppState) -> Router {
"/api/v2/libraries",
get(list_libraries).post(create_library),
)
.route(
"/api/v2/libraries/{library_id}/members",
get(list_library_members),
)
.route(
"/api/v2/libraries/{library_id}/members/{user_id}",
put(set_library_member).delete(remove_library_member),
Expand Down Expand Up @@ -127,6 +131,10 @@ pub fn router(state: AppState) -> Router {
get(get_track).patch(update_track),
)
.route("/api/v2/tracks/{track_id}/lyrics", get(get_track_lyrics))
.route(
"/api/v2/tracks/{track_id}/overrides",
get(get_track_overrides),
)
.route("/api/v2/albums", get(list_albums))
.route("/api/v2/genres", get(list_genres))
.route("/api/v2/albums/{album_id}", get(get_album))
Expand Down
26 changes: 26 additions & 0 deletions src/api/tracks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ pub async fn list_tracks(
Ok(Json(tracks))
}

/// What the file says and what a correction says instead.
///
/// Separate from `GET /api/v2/tracks/{track_id}`, which answers the effective
/// value and should keep doing so: the catalogue has no use for provenance,
/// and putting it on `SongItem` would weigh down the type every listing
/// returns — a type the frozen Subsonic façade also builds from.
///
/// It is the read half of correcting a tag. The write half, `PATCH
/// /api/v2/tracks/{track_id}`, still replaces the whole set, so a client that
/// means to change one field has to send back the others; until that is fixed
/// this route is what lets it know what they are.
#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/overrides", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::TrackOverrides), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn get_track_overrides(
State(state): State<AppState>,
Path(track_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<crate::services::TrackOverrides>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
.track_overrides(user.id, track_id)
.await
.map(Json)
.map_err(service_error)
}

#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn get_track(
State(state): State<AppState>,
Expand Down
6 changes: 6 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
api::start_scan,
api::list_libraries,
api::create_library,
api::list_library_members,
api::set_library_member,
api::remove_library_member,
api::scan_status,
Expand All @@ -136,6 +137,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
api::get_track,
api::update_track,
api::get_track_lyrics,
api::get_track_overrides,
api::list_albums,
api::list_genres,
api::get_album,
Expand Down Expand Up @@ -211,7 +213,11 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::AlbumItem,
services::ArtistItem,
services::GenreItem,
services::LibraryMember,
services::SongItem,
services::TrackOverrides,
services::TrackOverrideValues,
services::TrackSourceTags,
lyrics::LyricsList,
lyrics::StructuredLyrics,
lyrics::LyricsLine,
Expand Down
48 changes: 48 additions & 0 deletions src/services/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,54 @@
use super::*;

impl DomainServices {
/// Who may see a library, and in what standing.
///
/// The write side has existed since M4 — `PUT` and `DELETE` on
/// `/libraries/{id}/members/{user}` — with nothing to read it back, so a
/// screen could grant and revoke without ever showing who already had
/// access. That is not a listing anyone can build client-side: an account's
/// own membership tells it nothing about anyone else's.
///
/// Restricted to members, and by membership in the join rather than by a
/// check ahead of it: a library the caller is not in is missing, and never
/// confirmed to exist by a different answer.
pub async fn library_members(
&self,
user_id: Uuid,
library_id: Uuid,
) -> Result<Vec<LibraryMember>, ServiceError> {
let rows = sqlx::query(
"SELECT m.user_id, a.username, m.role, m.created_at \
FROM library_member m \
JOIN account a ON a.id=m.user_id \
WHERE m.library_id=? AND EXISTS ( \
SELECT 1 FROM library_member self \
WHERE self.library_id=m.library_id AND self.user_id=?) \
ORDER BY a.username COLLATE NOCASE",
)
.bind(library_id.to_string())
.bind(user_id.to_string())
.fetch_all(self.db.pool())
.await?;
// An empty answer would be indistinguishable from a library the caller
// cannot see, and every library has at least its owner — so nothing
// here means nothing to see.
if rows.is_empty() {
return Err(ServiceError::NotFound);
}
rows.into_iter()
.map(|row| {
Ok(LibraryMember {
user_id: parse_uuid(row.try_get("user_id")?)?,
username: row.try_get("username")?,
role: row.try_get("role")?,
created_at: row.try_get("created_at")?,
})
})
.collect::<Result<Vec<_>, sqlx::Error>>()
.map_err(ServiceError::from)
}

pub async fn users(&self, actor_id: Uuid) -> Result<Vec<UserItem>, ServiceError> {
self.require_admin(actor_id).await?;
let mut users = sqlx::query("SELECT a.id, a.username, a.role, a.disabled, c.user_id IS NOT NULL AS has_credential FROM account a LEFT JOIN subsonic_credential c ON c.user_id=a.id ORDER BY a.username COLLATE NOCASE")
Expand Down
65 changes: 65 additions & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,71 @@ pub struct LibraryEventPage {
pub purged_through: i64,
}

/// What a track's tags say, and what a correction says instead.
///
/// The editor's view of [`TrackMetadataPatch`]: every field it can write, with
/// the file's own value beside the correction standing over it. The catalogue
/// never shows this — it answers `effective`, which is `override ?? source` —
/// and nothing else needs it. It exists because an editor cannot offer to
/// restore a value it cannot read, and because a client that means to change
/// one correction has to know the others in order not to drop them.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TrackOverrides {
/// What the file said, for the fields where the database still knows.
pub source: TrackSourceTags,
/// The correction, field by field. `null` means no correction on that
/// field, which is what the row's nullable columns mean.
pub overrides: TrackOverrideValues,
}

/// The file's own values, as the last scan read them.
///
/// **Artists and genres are absent, and cannot be added here.** They are more
/// than columns: a correction to either is *materialised* into
/// `track_participant`, `track_genre` and the `*_display` strings, because
/// those rows feed every projection and the search index. Applying it
/// overwrites what the scan read, so once a correction exists the file's own
/// credits are no longer in the database — only in the file. The seven fields
/// below survive because the projection merges them with `COALESCE` instead,
/// leaving the scanned column untouched.
///
/// Restoring a corrected list therefore re-reads the file, which
/// `set_track_metadata` already does. What cannot be done cheaply is *showing*
/// it beforehand, so an editor offers the reset without previewing its result.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TrackSourceTags {
pub title: String,
pub sort_title: Option<String>,
pub year: Option<i64>,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub musicbrainz_recording_id: Option<String>,
pub comment: Option<String>,
}

/// The `track_override` row as it stands, not a projection of it.
#[derive(Debug, Clone, Default, Serialize, ToSchema)]
pub struct TrackOverrideValues {
pub title: Option<String>,
pub sort_title: Option<String>,
pub year: Option<i64>,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub musicbrainz_recording_id: Option<String>,
pub comment: Option<String>,
pub artists: Option<Vec<String>>,
pub genres: Option<Vec<String>>,
}

/// One account's standing in a library.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibraryMember {
pub user_id: Uuid,
pub username: String,
pub role: String,
pub created_at: i64,
}

/// Everything one account has starred, across the three entity kinds.
#[derive(Debug, Clone)]
pub struct StarredCatalog {
Expand Down
59 changes: 59 additions & 0 deletions src/services/track_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,63 @@ impl DomainServices {
.pop()
.ok_or(ServiceError::NotFound)
}

/// What the file says and what the correction says, side by side.
///
/// Read in one snapshot: a page that showed a source from before a write
/// and an override from after it would describe a track that never
/// existed. Tenancy is in the join, so a track in a library the caller is
/// not a member of is missing rather than forbidden.
///
/// `LEFT JOIN`, because most tracks carry no correction at all and an
/// absent row is an answer — every field `null` — rather than a 404.
pub async fn track_overrides(
&self,
user_id: Uuid,
track_id: Uuid,
) -> Result<TrackOverrides, ServiceError> {
let row = sqlx::query(
"SELECT t.title, t.sort_title, t.year, t.track_number, t.disc_number, t.musicbrainz_recording_id, t.comment, ovr.title AS o_title, ovr.sort_title AS o_sort_title, ovr.year AS o_year, ovr.track_number AS o_track_number, ovr.disc_number AS o_disc_number, ovr.musicbrainz_recording_id AS o_musicbrainz_recording_id, ovr.comment AS o_comment, ovr.artists AS o_artists, ovr.genres AS o_genres FROM track t JOIN library_member m ON m.library_id=t.library_id LEFT JOIN track_override ovr ON ovr.track_id=t.id WHERE t.id=? AND m.user_id=?",
)
.bind(track_id.to_string())
.bind(user_id.to_string())
.fetch_optional(self.db.pool())
.await?
.ok_or(ServiceError::NotFound)?;

// Stored as JSON rather than the `;`-joined form the tag columns use,
// because an override is a list someone typed on purpose. A row that
// will not parse is reported as no correction rather than taking the
// request down: the editor then shows the file's credits, which is the
// safe reading of a value nobody can interpret.
let list = |column: &str| -> Option<Vec<String>> {
row.try_get::<Option<String>, _>(column)
.ok()
.flatten()
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
};

Ok(TrackOverrides {
source: TrackSourceTags {
title: row.try_get("title")?,
sort_title: row.try_get("sort_title")?,
year: row.try_get("year")?,
track_number: row.try_get("track_number")?,
disc_number: row.try_get("disc_number")?,
musicbrainz_recording_id: row.try_get("musicbrainz_recording_id")?,
comment: row.try_get("comment")?,
},
overrides: TrackOverrideValues {
title: row.try_get("o_title")?,
sort_title: row.try_get("o_sort_title")?,
year: row.try_get("o_year")?,
track_number: row.try_get("o_track_number")?,
disc_number: row.try_get("o_disc_number")?,
musicbrainz_recording_id: row.try_get("o_musicbrainz_recording_id")?,
comment: row.try_get("o_comment")?,
artists: list("o_artists"),
genres: list("o_genres"),
},
})
}
}
Loading
Loading