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
1 change: 1 addition & 0 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ encoding_rs = "0.8.42"
futures-util = "0.3"
hex = "0.4"
lofty = "0.25"
# TTML lyrics uploads (RFC-012 decision 6). Already in the lock file through
# the artwork renderer; named here because the server now parses XML itself.
# Its default options refuse a DTD, which is what keeps entities out.
roxmltree = "0.21"
libc = "0.2"
md-5 = "0.11"
# Already in the tree through `url`; declared because `playlist_file` reads
Expand Down
10 changes: 6 additions & 4 deletions docs/rfcs/RFC-012-library-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
- **Status**: Proposed
- **Implemented by**: in progress, in the order of "The order of the work"
below: [#298](https://github.com/InstaZDLL/waveflow-server/pull/298) (the
context menu and Properties, with `relative_path` on the track) and the pull
request adding download (`?download=1` on the stream ticket). The *Status*
field above never flips in this project; this line is the one that says what
is running.
context menu and Properties, with `relative_path` on the track),
[#299](https://github.com/InstaZDLL/waveflow-server/pull/299) (download,
`?download=1` on the stream ticket) and the pull request adding uploaded
lyrics (`track_lyrics_upload`, the TTML parser, the order, and the lyrics
download). The *Status* field above never flips in this project; this line
is the one that says what is running.
- **Date**: 2026-10-21
- **Revised**: 2026-10-21, after review on #297: the **Received** folder's
contract, the depth check on a move, the folder cap that bounds the tree's
Expand Down
22 changes: 22 additions & 0 deletions migrations-v2/20261021000000_track_lyrics_upload.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Lyrics uploaded to a track (RFC-012 decision 6).
--
-- A table of its own because `track_lyrics` belongs to the scanner: every
-- scan rebuilds a track's rows from what the file and its sidecars hold, and
-- would erase anything else on the next pass. An upload is a correction, and
-- corrections live beside the file; nothing here is ever written to disk.
--
-- One upload per track and format: uploading again replaces it. `content` is
-- the uploaded file as it came, decoded to UTF-8, so a download gives back
-- what was uploaded rather than a re-serialisation of it.
CREATE TABLE track_lyrics_upload (
track_id TEXT NOT NULL,
library_id TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('lrc', 'txt', 'ttml')),
lang TEXT NOT NULL CHECK (length(trim(lang)) > 0),
content TEXT NOT NULL CHECK (length(content) > 0),
uploaded_by TEXT NOT NULL,
uploaded_at INTEGER NOT NULL,
PRIMARY KEY (track_id, format),
FOREIGN KEY (track_id, library_id)
REFERENCES track(id, library_id) ON DELETE CASCADE
) STRICT;
12 changes: 12 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ pub fn router(state: AppState) -> Router {
get(get_track).patch(update_track),
)
.route("/api/v2/tracks/{track_id}/lyrics", get(get_track_lyrics))
// A sheet is text, but a mebibyte of it: the one route besides the
// upload chunks that asks for more than the global 16 KiB.
.route(
"/api/v2/tracks/{track_id}/lyrics/{format}",
put(put_track_lyrics).delete(delete_track_lyrics).layer(
axum::extract::DefaultBodyLimit::max(crate::services::MAX_LYRICS_UPLOAD_BYTES),
),
)
.route(
"/api/v2/tracks/{track_id}/lyrics/file",
get(get_track_lyrics_file),
)
.route(
"/api/v2/tracks/{track_id}/overrides",
get(get_track_overrides),
Expand Down
95 changes: 94 additions & 1 deletion src/api/tracks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,107 @@ pub async fn get_track_lyrics(
headers: HeaderMap,
) -> Result<Json<crate::lyrics::LyricsList>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
// The native answer: uploads included, in RFC-012's order, each sheet
// naming its source. The Subsonic façade reads `lyrics` instead.
state
.services
.lyrics(user.id, track_id)
.track_lyrics(user.id, track_id)
.await
.map(Json)
.map_err(service_error)
}

fn lyrics_format(value: &str) -> Result<crate::lyrics::UploadFormat, ApiError> {
crate::lyrics::UploadFormat::parse(value).ok_or(ApiError::NotFound)
}

/// Uploads a lyrics sheet to a track (RFC-012 decision 6).
///
/// The body is the file, as it was saved: `lrc`, `txt` or `ttml`, at most a
/// mebibyte, in any encoding `waveflow_core::text_encoding` reads. Uploading
/// again in the same format replaces it. A sheet with nothing usable in it is
/// refused with `422`. Owners and managers only; anyone else gets the `404` a
/// track they could not see would give.
#[utoipa::path(put, path = "/api/v2/tracks/{track_id}/lyrics/{format}", tag = "catalog", params(("track_id" = Uuid, Path), ("format" = String, Path, description = "lrc, txt or ttml")), request_body(content = Vec<u8>, content_type = "application/octet-stream"), responses((status = 200, body = crate::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413), (status = 422, body = ErrorResponse)))]
pub async fn put_track_lyrics(
State(state): State<AppState>,
Path((track_id, format)): Path<(Uuid, String)>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<crate::lyrics::LyricsList>, ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
let format = lyrics_format(&format)?;
state
.services
.upload_lyrics(user.id, track_id, format, &body)
.await
.map(Json)
.map_err(service_error)
}

/// Removes an uploaded sheet; the track falls back to what the scanner found.
#[utoipa::path(delete, path = "/api/v2/tracks/{track_id}/lyrics/{format}", tag = "catalog", params(("track_id" = Uuid, Path), ("format" = String, Path, description = "lrc, txt or ttml")), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn delete_track_lyrics(
State(state): State<AppState>,
Path((track_id, format)): Path<(Uuid, String)>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
let format = lyrics_format(&format)?;
state
.services
.delete_lyrics_upload(user.id, track_id, format)
.await
.map(|()| StatusCode::NO_CONTENT)
.map_err(service_error)
}

#[derive(Debug, Deserialize)]
pub struct LyricsFileQuery {
pub format: Option<String>,
}

/// A track's lyrics as a file to save.
///
/// The sheet the lyrics route puts first, or with `format`, one upload by
/// name. Never rendered: `application/octet-stream`, an attachment, and
/// `nosniff`, whatever the format — an uploaded `.ttml` is XML somebody else
/// wrote, and inline under an XML type it would be a document running in this
/// server's origin.
#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/lyrics/file", tag = "catalog", params(("track_id" = Uuid, Path), ("format" = Option<String>, Query, description = "lrc, txt or ttml: one upload by name")), responses((status = 200, content_type = "application/octet-stream"), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn get_track_lyrics_file(
State(state): State<AppState>,
Path(track_id): Path<Uuid>,
Query(query): Query<LyricsFileQuery>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
let format = query.format.as_deref().map(lyrics_format).transpose()?;
let file = state
.services
.lyrics_file(user.id, track_id, format)
.await
.map_err(service_error)?;
Ok((
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
),
(
header::CONTENT_DISPOSITION,
crate::media::attachment(&file.name),
),
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
],
file.content,
)
.into_response())
}

/// Corrects some of a track's tags, leaving the rest as they are.
///
/// A partial patch in three states. A field **absent** from the body leaves
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
api::get_track,
api::update_track,
api::get_track_lyrics,
api::put_track_lyrics,
api::delete_track_lyrics,
api::get_track_lyrics_file,
api::get_track_overrides,
api::list_albums,
api::list_genres,
Expand Down
Loading
Loading