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
18 changes: 11 additions & 7 deletions docs/rfcs/RFC-012-library-browser.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# RFC-012 — The library browser: folders as a view, filing as metadata

- **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
- **Implemented by**: every step of "The order of the work" below, in that
order, after this RFC itself ([#297](https://github.com/InstaZDLL/waveflow-server/pull/297)):
[#298](https://github.com/InstaZDLL/waveflow-server/pull/298) (the
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),
[#300](https://github.com/InstaZDLL/waveflow-server/pull/300) (uploaded
lyrics: `track_lyrics_upload`, the TTML parser, the order, and the lyrics
download) and the pull request adding the disk tree (`GET
/libraries/{id}/tree` and the Folders page). The *Status* field above never
flips in this project; this line is the one that says what is running.
download), [#301](https://github.com/InstaZDLL/waveflow-server/pull/301) (the
disk tree, `GET /libraries/{id}/tree`, and the Folders page) and the pull
request adding the filed tree (`library_folder`, `track_placement`, their
routes, "Move to folder…", drag and drop, and uploads into a folder). 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 Expand Up @@ -318,11 +322,11 @@ library or a track the caller may not see.
| Route | What |
| --- | --- |
| `GET /libraries/{id}/tree?path=` | One level of the disk tree: sub-folders with their track counts, and the tracks directly in `path` (empty for the root; `.waveflow-managed` for **Received**). Paged, like every listing. |
| `GET /libraries/{id}/folders` | The whole filed tree, without tracks, as one flat list of `id`, `parent_id`, `name` and the count of tracks filed directly in each. Not paged: bounded by the 5,000-folder cap of decision 2, which is what lets a client draw the tree from one answer. |
| `GET /libraries/{id}/folders` | The whole filed tree, without tracks, as one flat list of `id`, `parent_id`, `name` and the count of tracks filed directly in each, under `folders`, beside `unfiled_track_count` for the root's unfiled entry. Not paged: bounded by the 5,000-folder cap of decision 2, which is what lets a client draw the tree from one answer. |
| `POST /libraries/{id}/folders` | Create: `name`, `parent_id`. |
| `PATCH /folders/{id}` | Rename, or move under another `parent_id` (or to the root); `422` past the depth cap for the whole sub-tree, or on a cycle. |
| `DELETE /folders/{id}` | Delete with its sub-folders; returns the number of tracks unfiled. |
| `GET /folders/{id}/tracks` | The tracks filed directly in a folder. Paged. `unfiled` stands for the root's unfiled entry. |
| `GET /libraries/{id}/folders/{folder}/tracks` | The tracks filed directly in a folder. Paged. `unfiled` in place of a folder id stands for the root's unfiled entry, which is why the library is in the path: unfiled names no folder to find it by. |
| `PUT /libraries/{id}/placements` | File up to 500 tracks into one folder, or unfile them (`folder_id: null`). **All or nothing**: the folder and every track are checked before anything is written, in one transaction under the writer gate; any track the caller may not see, or outside the library, answers `404`, a folder outside it `404`, more than 500 `422`, and no placement changes. |
| `PUT /tracks/{id}/lyrics/{format}` | Upload `lrc`, `txt` or `ttml`. |
| `DELETE /tracks/{id}/lyrics/{format}` | Remove an upload. |
Expand Down
48 changes: 48 additions & 0 deletions migrations-v2/20261021010000_library_folder.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Filed folders, and where each track is filed (RFC-012 decision 2).
--
-- A filed folder exists only here: nothing on disk is created, moved, renamed
-- or deleted by anything that writes these tables. The disk tree is derived
-- from `track.relative_path` and is never stored.
--
-- `library_id` rides on every row so a parent, a folder and a track can only
-- ever meet inside one library: the composite keys make a cross-library
-- reference impossible to write rather than merely checked.
CREATE TABLE library_folder (
id TEXT PRIMARY KEY,
library_id TEXT NOT NULL REFERENCES library(id) ON DELETE CASCADE,
-- NULL at the root. Deleting a folder takes its sub-folders with it.
parent_id TEXT,
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
-- The name folded for comparison, written by the server: SQLite's NOCASE
-- folds ASCII only, and "Été" and "été" must collide among siblings.
name_key TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (id, library_id),
FOREIGN KEY (parent_id, library_id)
REFERENCES library_folder(id, library_id) ON DELETE CASCADE
) STRICT;

-- Sibling names are unique, the root counting as one parent. The service
-- checks first, to answer cleanly; this is what holds if it ever did not.
CREATE UNIQUE INDEX library_folder_sibling_name
ON library_folder(library_id, ifnull(parent_id, ''), name_key);
CREATE INDEX library_folder_parent ON library_folder(parent_id);

-- At most one filed folder per track: the track id is the key. A track in no
-- folder is unfiled and has no row. A track deleted by a scan, and a folder
-- deleted by its owner, take their placements with them.
CREATE TABLE track_placement (
track_id TEXT PRIMARY KEY,
library_id TEXT NOT NULL,
folder_id TEXT NOT NULL,
placed_by TEXT NOT NULL,
placed_at INTEGER NOT NULL,
FOREIGN KEY (track_id, library_id)
REFERENCES track(id, library_id) ON DELETE CASCADE,
FOREIGN KEY (folder_id, library_id)
REFERENCES library_folder(id, library_id) ON DELETE CASCADE
) STRICT;

CREATE INDEX track_placement_folder ON track_placement(folder_id);
144 changes: 144 additions & 0 deletions src/api/folders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! Filed folders and placements (RFC-012 decisions 2 and 3).
//!
//! Handlers orchestrate HTTP only; the rules live in `DomainServices`. Reads
//! are open to any member, writes to owners and managers, and everyone else
//! gets the same `404` a missing folder would.

use super::*;

#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
/// `null` or absent for the root.
#[serde(default)]
pub parent_id: Option<Uuid>,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct PlacementRequest {
/// Up to 500 tracks of the library.
pub track_ids: Vec<Uuid>,
/// The folder to file them in, or `null` to unfile them.
pub folder_id: Option<Uuid>,
}

#[derive(Debug, Deserialize)]
pub struct FolderTracksQuery {
pub offset: Option<i64>,
pub limit: Option<i64>,
}

/// The body a placement may carry: the most tracks one request files, each
/// as a quoted UUID and its comma, and room for the envelope.
pub(super) fn placement_body_limit() -> usize {
crate::services::MAX_PLACEMENTS * 48 + 1024
}

/// The whole filed tree of a library, flat, with the count of unfiled tracks.
#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/folders", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 200, body = crate::services::FiledTree), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn list_folders(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<crate::services::FiledTree>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
state
.services
.filed_tree(user.id, library_id)
.await
.map(Json)
.map_err(service_error)
}

/// Creates a folder at the root or under `parent_id`. `409` for a sibling of
/// the same name, `422` past the depth or folder cap.
#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/folders", tag = "catalog", params(("library_id" = Uuid, Path)), request_body = CreateFolderRequest, responses((status = 201, body = crate::services::FiledFolder), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
pub async fn create_folder(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
headers: HeaderMap,
Json(request): Json<CreateFolderRequest>,
) -> Result<(StatusCode, Json<crate::services::FiledFolder>), ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
state
.services
.create_folder(user.id, library_id, &request.name, request.parent_id)
.await
.map(|folder| (StatusCode::CREATED, Json(folder)))
.map_err(service_error)
}

/// Renames a folder, moves it (`parent_id: null` for the root), or both.
#[utoipa::path(patch, path = "/api/v2/folders/{folder_id}", tag = "catalog", params(("folder_id" = Uuid, Path)), request_body = crate::services::FolderPatch, responses((status = 200, body = crate::services::FiledFolder), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
headers: HeaderMap,
Json(patch): Json<crate::services::FolderPatch>,
) -> Result<Json<crate::services::FiledFolder>, ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
state
.services
.update_folder(user.id, folder_id, patch)
.await
.map(Json)
.map_err(service_error)
}

/// Deletes a folder and its sub-folders; their tracks become unfiled.
#[utoipa::path(delete, path = "/api/v2/folders/{folder_id}", tag = "catalog", params(("folder_id" = Uuid, Path)), responses((status = 200, body = crate::services::FolderRemoved), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))]
pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Json<crate::services::FolderRemoved>, ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
state
.services
.delete_folder(user.id, folder_id)
.await
.map(Json)
.map_err(service_error)
}

/// The tracks filed directly in a folder, paged; `unfiled` in place of an id
/// for the library's unfiled tracks.
#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/folders/{folder}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("folder" = String, Path, description = "a folder id, or unfiled"), ("offset" = Option<i64>, Query), ("limit" = Option<i64>, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
pub async fn folder_tracks(
State(state): State<AppState>,
Path((library_id, folder)): Path<(Uuid, String)>,
Query(query): Query<FolderTracksQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<crate::services::SongItem>>, ApiError> {
let user = authenticated(&state, &headers, Access::Read).await?;
let folder = match folder.as_str() {
"unfiled" => None,
id => Some(Uuid::parse_str(id).map_err(|_| ApiError::NotFound)?),
};
let page =
crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?;
state
.services
.folder_tracks(user.id, library_id, folder, page)
.await
.map(Json)
.map_err(service_error)
}

/// Files up to 500 tracks into one folder, or unfiles them with
/// `folder_id: null`. All or nothing.
#[utoipa::path(put, path = "/api/v2/libraries/{library_id}/placements", tag = "catalog", params(("library_id" = Uuid, Path)), request_body = PlacementRequest, responses((status = 200, body = crate::services::Placed), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413, body = ErrorResponse), (status = 422, body = ErrorResponse)))]
pub async fn place_tracks(
State(state): State<AppState>,
Path(library_id): Path<Uuid>,
headers: HeaderMap,
Json(request): Json<PlacementRequest>,
) -> Result<Json<crate::services::Placed>, ApiError> {
let user = authenticated(&state, &headers, Access::Write).await?;
state
.services
.place_tracks(user.id, library_id, &request.track_ids, request.folder_id)
.await
.map(Json)
.map_err(service_error)
}
20 changes: 20 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod bookmarks;
mod catalog;
mod error;
mod favorites;
mod folders;
mod libraries;
mod oauth;
mod playback;
Expand All @@ -51,6 +52,7 @@ pub use bookmarks::*;
pub use catalog::*;
pub use error::*;
pub use favorites::*;
pub use folders::*;
pub use libraries::*;
pub use oauth::*;
pub use playback::*;
Expand Down Expand Up @@ -114,6 +116,24 @@ pub fn router(state: AppState) -> Router {
.route("/api/v2/scans/{scan_id}/events", get(scan_events))
.route("/api/v2/libraries/{library_id}/tracks", get(list_tracks))
.route("/api/v2/libraries/{library_id}/tree", get(library_tree))
.route(
"/api/v2/libraries/{library_id}/folders",
get(list_folders).post(create_folder),
)
.route(
"/api/v2/libraries/{library_id}/folders/{folder}/tracks",
get(folder_tracks),
)
.route(
"/api/v2/libraries/{library_id}/placements",
put(place_tracks).layer(axum::extract::DefaultBodyLimit::max(
folders::placement_body_limit(),
)),
)
.route(
"/api/v2/folders/{folder_id}",
axum::routing::patch(update_folder).delete(delete_folder),
)
.route("/api/v2/libraries/{library_id}/events", get(library_events))
.route(
"/api/v2/libraries/{library_id}/events/ack",
Expand Down
13 changes: 13 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
api::commit_upload,
api::list_tracks,
api::library_tree,
api::list_folders,
api::create_folder,
api::update_folder,
api::delete_folder,
api::folder_tracks,
api::place_tracks,
api::get_track,
api::update_track,
api::get_track_lyrics,
Expand Down Expand Up @@ -300,6 +306,13 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize {
services::DiskListing,
services::DiskFolder,
services::DiskTrack,
services::FiledFolder,
services::FiledTree,
services::FolderPatch,
services::FolderRemoved,
services::Placed,
api::CreateFolderRequest,
api::PlacementRequest,
services::TrackMetadataPatch,
services::UploadOffer,
services::UploadVerdict,
Expand Down
Loading
Loading