From b39c1e800e273b3ab71512347ad6d6325a26219d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 09:09:40 +0200 Subject: [PATCH 1/2] feat: browse a library's folders on disk GET /api/v2/libraries/{id}/tree lists one level of the disk tree, derived from the indexed paths: the sub-folders with their direct and nested track counts, then the tracks directly in the folder under their file names, paged as one list. Any member reads it; a stranger and a folder no available track runs through get 404, a malformed path 422. The received folder keeps its real path and sorts by artist then title. The web client gets a Folders page: the folder in the address, a trail back up, Received under its label, the file name under each title, and a link to the upload screen where the library takes files. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-012-library-browser.md | 10 +- src/api/libraries.rs | 52 +++++ src/api/mod.rs | 1 + src/lib.rs | 4 + src/services/library_tree.rs | 306 +++++++++++++++++++++++++++ src/services/mod.rs | 2 + tests/native_api.rs | 234 ++++++++++++++++++++ webapp/e2e/studio-nocturne.spec.ts | 132 +++++++++++- webapp/src/api/index.ts | 12 +- webapp/src/api/libraries.ts | 52 +++++ webapp/src/api/queries.ts | 19 ++ webapp/src/components/icons.tsx | 7 +- webapp/src/components/song-table.tsx | 9 +- webapp/src/main.tsx | 27 +++ webapp/src/pages/folders-page.tsx | 199 +++++++++++++++++ webapp/src/state/i18n.tsx | 20 ++ webapp/src/styles/styles.css | 89 ++++++++ 17 files changed, 1164 insertions(+), 11 deletions(-) create mode 100644 src/services/library_tree.rs create mode 100644 webapp/src/pages/folders-page.tsx diff --git a/docs/rfcs/RFC-012-library-browser.md b/docs/rfcs/RFC-012-library-browser.md index c3bf9c55..5354b051 100644 --- a/docs/rfcs/RFC-012-library-browser.md +++ b/docs/rfcs/RFC-012-library-browser.md @@ -5,10 +5,12 @@ below: [#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) 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. + `?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. - **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 diff --git a/src/api/libraries.rs b/src/api/libraries.rs index f0a51692..c753bc45 100644 --- a/src/api/libraries.rs +++ b/src/api/libraries.rs @@ -332,3 +332,55 @@ pub async fn library_events( .map(Json) .map_err(service_error) } + +#[derive(Debug, Deserialize)] +pub struct DiskTreeQuery { + /// The folder to list, relative to the library; absent or empty for its + /// root, `.waveflow-managed` for the files received through uploads. + pub path: Option, + pub offset: Option, + pub limit: Option, +} + +/// One level of a library's disk tree (RFC-012 decision 1): the sub-folders +/// of `path` with their track counts, then the tracks directly in it, paged as +/// one list. Derived from the indexed paths, read-only, and open to every +/// member of the library. +#[utoipa::path( + get, + path = "/api/v2/libraries/{library_id}/tree", + tag = "catalog", + params( + ("library_id" = Uuid, Path), + ("path" = Option, Query), + ("offset" = Option, Query), + ("limit" = Option, Query) + ), + responses( + (status = 200, body = crate::services::DiskListing), + (status = 401, body = ErrorResponse), + (status = 404, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn library_tree( + State(state): State, + Path(library_id): Path, + Query(query): Query, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let page = + crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; + state + .services + .disk_tree( + user.id, + library_id, + query.path.as_deref().unwrap_or(""), + page, + ) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 60c0f703..bd42bf0f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -113,6 +113,7 @@ pub fn router(state: AppState) -> Router { .route("/api/v2/scans/{scan_id}", get(scan_status)) .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}/events", get(library_events)) .route( "/api/v2/libraries/{library_id}/events/ack", diff --git a/src/lib.rs b/src/lib.rs index 91559084..82455d40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -139,6 +139,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::upload_chunk, api::commit_upload, api::list_tracks, + api::library_tree, api::get_track, api::update_track, api::get_track_lyrics, @@ -296,6 +297,9 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::SyncAckRequest, api::SyncSnapshot, services::LibraryEventPage, + services::DiskListing, + services::DiskFolder, + services::DiskTrack, services::TrackMetadataPatch, services::UploadOffer, services::UploadVerdict, diff --git a/src/services/library_tree.rs b/src/services/library_tree.rs new file mode 100644 index 00000000..73b78987 --- /dev/null +++ b/src/services/library_tree.rs @@ -0,0 +1,306 @@ +//! The disk tree of a library: one level at a time, read-only (RFC-012 +//! decision 1). +//! +//! The tree is derived from `track.relative_path`, which the scanner stores +//! with `/` on every platform. Nothing here lists a directory: a folder exists +//! because an available track's path runs through it, so a folder holding no +//! indexed audio does not appear, and what appears is what the last scan saw. +//! The library's root path is never read, let alone returned. + +use serde::Serialize; +use sqlx::Row; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ + attach_song_relations, song_from_row, BrowsePage, DomainServices, ServiceError, SongItem, +}; +use crate::services::uploads::MANAGED_DIR; + +/// The longest `path` a request may name. Far past any real tree, and short +/// enough that a path made of nothing but separators cannot weigh on a query. +const MAX_TREE_PATH_CHARS: usize = 4096; + +/// A sub-folder in a listing. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct DiskFolder { + /// Its last segment. + pub name: String, + /// The address to ask for its own listing with: relative to the library, + /// segments joined by `/`. + pub path: String, + /// The available tracks directly in it — the count RFC-012 gives a folder. + pub track_count: i64, + /// The available tracks anywhere beneath it, its own included: an artist's + /// folder holds albums, not tracks, and would otherwise read as empty. + pub nested_track_count: i64, +} + +/// One level of the disk tree. +/// +/// A page runs over the folders first, then the tracks, so `offset` and +/// `limit` page the two as one list. The totals say how long each part is. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct DiskListing { + /// The folder listed: empty for the library's root. + pub path: String, + pub folder_total: i64, + pub track_total: i64, + pub folders: Vec, + pub tracks: Vec, +} + +/// A track in a listing, under the name its file has. +/// +/// The name rides beside the song rather than in it: `SongItem` keeps +/// `relative_path` to itself because every listing and the Subsonic façade +/// share it, and a path is shown where it is asked for (RFC-012, "Paths are +/// shown to members, and only to them"). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct DiskTrack { + /// The file name, extension included. + pub name: String, + pub song: SongItem, +} + +/// A requested path, checked and split into the bounds the queries use. +struct TreePath { + path: String, + /// What every path beneath it starts with, `/` included; empty at the root. + prefix: String, + /// The first path past all of them: the folder's own path with `0`, the + /// character after `/`, in place of the separator. Byte order is SQLite's + /// `BINARY` collation, so `prefix <= p < upper` is exactly "starts with + /// `prefix`", and it is answered from the `(library_id, relative_path)` + /// index. `None` at the root, which bounds nothing. + upper: Option, +} + +impl TreePath { + /// `None` for anything that is not a plain relative path: a leading or + /// trailing separator, an empty segment, `.` or `..`, a backslash. The + /// path never reaches the file system, so this guards no traversal; it + /// keeps one folder from having several spellings. + fn parse(path: &str) -> Option { + if path.is_empty() { + return Some(Self { + path: String::new(), + prefix: String::new(), + upper: None, + }); + } + if path.chars().count() > MAX_TREE_PATH_CHARS + || path.contains(['\\', '\0']) + || path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return None; + } + Some(Self { + path: path.to_owned(), + prefix: format!("{path}/"), + upper: Some(format!("{path}0")), + }) + } + + /// Where the part below the folder starts, as SQLite's `substr` counts: + /// in characters, from one. + fn rest_from(&self) -> i64 { + self.prefix.chars().count() as i64 + 1 + } + + fn child(&self, name: &str) -> String { + format!("{}{name}", self.prefix) + } +} + +impl DomainServices { + /// One level of a library's disk tree: the sub-folders of `path`, with + /// their counts, then the tracks directly in it. + /// + /// Any member reads it, listeners included (RFC-012 decision 3): every one + /// of them already reads each track's `relative_path`, and this is only + /// those paths, grouped. `NotFound` for a library the caller is not a + /// member of, and for a folder no available track runs through; the root + /// of an empty library is an empty listing. `Invalid` for a malformed path. + /// + /// Folders sort by name, case-insensitively. Tracks sort by file name, + /// except in the received folder, whose file names are hashes: there they + /// sort by artist then title, a missing artist last. + pub async fn disk_tree( + &self, + user_id: Uuid, + library_id: Uuid, + path: &str, + page: BrowsePage, + ) -> Result { + let tree = TreePath::parse(path).ok_or(ServiceError::Invalid)?; + if self + .db + .library_for_user(user_id, library_id) + .await? + .is_none() + { + return Err(ServiceError::NotFound); + } + + // One pass over the paths beneath the folder, grouped by their first + // segment; the paths with no segment left over are the folder's own + // tracks, and gather under a NULL name. + let groups = sqlx::query( + "WITH beneath AS ( \ + SELECT substr(t.relative_path, ?) AS rest FROM track t \ + JOIN library_member m ON m.library_id=t.library_id \ + WHERE m.user_id=? AND t.library_id=? AND t.is_available=1 \ + AND t.relative_path >= ? AND (? IS NULL OR t.relative_path < ?)) \ + SELECT CASE WHEN instr(rest, '/') > 0 \ + THEN substr(rest, 1, instr(rest, '/') - 1) END AS name, \ + COUNT(*) AS nested, \ + SUM(instr(rest, '/') > 0 \ + AND instr(substr(rest, instr(rest, '/') + 1), '/') = 0) AS direct \ + FROM beneath GROUP BY name \ + ORDER BY name IS NOT NULL, name COLLATE NOCASE, name", + ) + .bind(tree.rest_from()) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .bind(&tree.prefix) + .bind(tree.upper.as_deref()) + .bind(tree.upper.as_deref()) + .fetch_all(self.db.pool()) + .await?; + + let mut track_total = 0; + let mut folders = Vec::with_capacity(groups.len()); + for row in groups { + let nested: i64 = row.try_get("nested")?; + match row.try_get::, _>("name")? { + None => track_total = nested, + Some(name) => folders.push(DiskFolder { + path: tree.child(&name), + name, + track_count: row.try_get("direct")?, + nested_track_count: nested, + }), + } + } + if !tree.path.is_empty() && folders.is_empty() && track_total == 0 { + return Err(ServiceError::NotFound); + } + + let folder_total = folders.len() as i64; + let (offset, limit) = (page.offset, page.limit); + let folders: Vec = folders + .into_iter() + .skip(offset.min(folder_total) as usize) + .take(limit as usize) + .collect(); + let track_limit = limit - folders.len() as i64; + let track_offset = (offset - folder_total).max(0); + + let mut tracks = Vec::new(); + if track_limit > 0 && track_offset < track_total { + // The tracks whose path ends in the folder: beneath it, with no + // separator left after its prefix. + macro_rules! directly_in { + () => { + " AND t.library_id=? AND t.relative_path >= ? \ + AND (? IS NULL OR t.relative_path < ?) \ + AND instr(substr(t.relative_path, ?), '/') = 0" + }; + } + let sql = if tree.path == MANAGED_DIR { + concat!( + song_select!(), + directly_in!(), + " ORDER BY t.artist_display COLLATE NOCASE NULLS LAST, \ + COALESCE(ovr.title, t.title) COLLATE NOCASE, t.id LIMIT ? OFFSET ?" + ) + } else { + concat!( + song_select!(), + directly_in!(), + " ORDER BY t.relative_path COLLATE NOCASE, t.relative_path LIMIT ? OFFSET ?" + ) + }; + tracks = sqlx::query(sql) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .bind(&tree.prefix) + .bind(tree.upper.as_deref()) + .bind(tree.upper.as_deref()) + .bind(tree.rest_from()) + .bind(track_limit) + .bind(track_offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut tracks) + .await?; + } + let tracks = tracks + .into_iter() + .map(|song| DiskTrack { + name: song + .relative_path + .rsplit('/') + .next() + .unwrap_or_default() + .to_owned(), + song, + }) + .collect(); + + Ok(DiskListing { + path: tree.path, + folder_total, + track_total, + folders, + tracks, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tree_path_has_one_spelling() { + for refused in [ + "/Artist", + "Artist/", + "Artist//Album", + "./Artist", + "Artist/..", + "A\\B", + "A\0B", + ] { + assert!(TreePath::parse(refused).is_none(), "{refused:?}"); + } + let root = TreePath::parse("").unwrap(); + assert_eq!((root.prefix.as_str(), root.upper), ("", None)); + let album = TreePath::parse("Björk/Post").unwrap(); + assert_eq!(album.prefix, "Björk/Post/"); + assert_eq!(album.upper.as_deref(), Some("Björk/Post0")); + // Counted in characters, as `substr` counts: the ö is one. + assert_eq!(album.rest_from(), 12); + assert_eq!(album.child("Disc 1"), "Björk/Post/Disc 1"); + } + + #[test] + fn the_upper_bound_admits_exactly_the_paths_beneath() { + let tree = TreePath::parse("A").unwrap(); + let upper = tree.upper.unwrap(); + let beneath = |path: &str| path >= tree.prefix.as_str() && path < upper.as_str(); + assert!(beneath("A/song.flac")); + assert!(beneath("A/B/song.flac")); + assert!(!beneath("A.flac")); + assert!(!beneath("A-side/song.flac")); + assert!(!beneath("AB/song.flac")); + assert!(!beneath("A0/song.flac")); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 044e834f..5b24b2b3 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1364,6 +1364,7 @@ mod credentials; mod favorites; mod lastfm; mod library_events; +mod library_tree; mod lyrics_uploads; mod playback; mod playlists; @@ -1393,6 +1394,7 @@ pub use lastfm::{ LastFmApproval, LastFmJourneyStart, LastFmSessionExchange, LASTFM_CALLBACK_PREFIX, LASTFM_JOURNEY_COOKIE, }; +pub use library_tree::{DiskFolder, DiskListing, DiskTrack}; pub use lyrics_uploads::{LyricsFile, MAX_UPLOAD_BYTES as MAX_LYRICS_UPLOAD_BYTES}; pub use scrobbling::{ ScrobbleDestinationName, ScrobbleDrain, ScrobbleEnvelope, ScrobbleLinkState, ScrobbleProvider, diff --git a/tests/native_api.rs b/tests/native_api.rs index 42b1e39f..d139871d 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -4340,3 +4340,237 @@ async fn uploaded_lyrics_lead_the_order_download_as_they_came_and_stay_off_subso .unwrap(); assert_eq!(stored, 0); } + +/// The disk tree (RFC-012 decision 1), through the router: one level at a +/// time, derived from the indexed paths, open to every member and to nobody +/// else. +/// +/// The paths carry a non-ASCII segment because SQLite's `substr` counts +/// characters, and a neighbour that shares the folder's name as a prefix +/// (`Björk-B`) because the range that selects "beneath" must stop at the +/// separator. Passwords are drawn at run time, for CodeQL. +#[tokio::test] +async fn the_disk_tree_lists_one_level_of_the_indexed_paths_to_members_only() { + let (_temp, config, state) = test_app().await; + let password = uuid::Uuid::new_v4().to_string(); + let hash = security::hash_password(&password).unwrap(); + let mut accounts = Vec::new(); + for name in ["tree-owner", "tree-listener", "tree-stranger"] { + accounts.push( + state + .db + .create_account(name, &hash, AccountRole::User, now_ms()) + .await + .unwrap(), + ); + } + let (owner, listener) = (accounts[0], accounts[1]); + let root = std::fs::canonicalize(&config.data_dir).unwrap(); + let library_id = state + .db + .create_library(owner, "Tree", &root, LibraryVisibility::Private, now_ms()) + .await + .unwrap(); + state + .db + .add_library_member( + owner, + library_id, + listener, + waveflow_server::database::LibraryRole::Listener, + now_ms(), + ) + .await + .unwrap(); + let scan_id = state + .db + .create_scan_job(library_id, Some(owner), "manual") + .await + .unwrap(); + let files: [(&str, &str, Option<&str>); 10] = [ + ( + "Björk/Post/02 Hyperballad.flac", + "Hyperballad", + Some("Björk"), + ), + ("Björk/Post/01 Army Of Me.flac", "Army Of Me", Some("Björk")), + ("Björk/Post/Bonus/01 Extra.flac", "Extra", Some("Björk")), + ("Björk/Homogenic/01 Hunter.flac", "Hunter", Some("Björk")), + ("Björk-B/Side.flac", "Side", Some("Other")), + ("Loose.flac", "Loose", None), + (".waveflow-managed/3f.flac", "Zebra", Some("zed")), + (".waveflow-managed/1a.flac", "Nameless", None), + (".waveflow-managed/2b.flac", "Waterloo", Some("ABBA")), + ("Gone/Missing.flac", "Missing", Some("Ghost")), + ]; + state + .db + .start_scan_job(scan_id, files.len() as i64, false) + .await + .unwrap(); + for (index, (path, title, artist)) in files.iter().enumerate() { + let mut input = browse_input(20_000 + index, title, "Album", "x", None, None); + input.relative_path = (*path).to_owned(); + input.artist = artist.map(str::to_owned); + input.album_artist = artist.map(str::to_owned); + state + .db + .apply_catalog_track(library_id, scan_id, &input, None, false) + .await + .unwrap(); + } + state.db.finish_scan_job(scan_id, 0).await.unwrap(); + // A track whose file is gone holds no folder open. + sqlx::query("UPDATE track SET is_available = 0 WHERE relative_path = 'Gone/Missing.flac'") + .execute(state.db.pool()) + .await + .unwrap(); + + let router = waveflow_server::app(&config, state.clone()); + let owner_token = login_token(&router, "tree-owner", &password).await; + let listener_token = login_token(&router, "tree-listener", &password).await; + let stranger_token = login_token(&router, "tree-stranger", &password).await; + let tree = |token: &str, query: &str| { + let router = router.clone(); + let token = token.to_owned(); + let uri = format!("/api/v2/libraries/{library_id}/tree{query}"); + async move { + router + .oneshot( + Request::get(uri) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + }; + let path = |path: &str| { + format!( + "?path={}", + percent_encoding::utf8_percent_encode(path, percent_encoding::NON_ALPHANUMERIC) + ) + }; + let folders = |listing: &serde_json::Value| -> Vec<(String, String, i64, i64)> { + listing["folders"] + .as_array() + .unwrap() + .iter() + .map(|folder| { + ( + folder["name"].as_str().unwrap().to_owned(), + folder["path"].as_str().unwrap().to_owned(), + folder["track_count"].as_i64().unwrap(), + folder["nested_track_count"].as_i64().unwrap(), + ) + }) + .collect() + }; + let titles = |listing: &serde_json::Value| -> Vec { + listing["tracks"] + .as_array() + .unwrap() + .iter() + .map(|track| track["song"]["title"].as_str().unwrap().to_owned()) + .collect() + }; + let folder = |name: &str, path: &str, direct: i64, nested: i64| { + (name.to_owned(), path.to_owned(), direct, nested) + }; + + // The root: the received folder under its real path, the artist folder + // counting what is beneath it, the neighbour apart, and no `Gone`. + let top = json_body(tree(&owner_token, "").await).await; + assert_eq!(top["path"], ""); + assert_eq!( + folders(&top), + [ + folder(".waveflow-managed", ".waveflow-managed", 3, 3), + folder("Björk", "Björk", 0, 4), + folder("Björk-B", "Björk-B", 1, 1), + ] + ); + assert_eq!( + (top["folder_total"].as_i64(), top["track_total"].as_i64()), + (Some(3), Some(1)) + ); + assert_eq!(titles(&top), ["Loose"]); + // A listener reads the same tree; the answer is the owner's, to the byte. + let heard = json_body(tree(&listener_token, "").await).await; + assert_eq!(heard, top); + + let artist = json_body(tree(&owner_token, &path("Björk")).await).await; + assert_eq!( + folders(&artist), + [ + folder("Homogenic", "Björk/Homogenic", 1, 1), + folder("Post", "Björk/Post", 2, 3), + ] + ); + assert_eq!(artist["track_total"], 0); + + // A folder's tracks sort by file name, and carry it; the song itself + // keeps the shape every other listing gives it, path left out. + let album = json_body(tree(&owner_token, &path("Björk/Post")).await).await; + assert_eq!(folders(&album), [folder("Bonus", "Björk/Post/Bonus", 1, 1)]); + assert_eq!(titles(&album), ["Army Of Me", "Hyperballad"]); + assert_eq!(album["tracks"][0]["name"], "01 Army Of Me.flac"); + assert!(omits(&album["tracks"][0]["song"], "relative_path")); + + // One page runs over the folders, then the tracks. + let pages = [ + ("&limit=1", vec!["Bonus"], vec![]), + ("&offset=1&limit=1", vec![], vec!["Army Of Me"]), + ("&offset=2&limit=5", vec![], vec!["Hyperballad"]), + ]; + for (window, expected_folders, expected_tracks) in pages { + let page = + json_body(tree(&owner_token, &format!("{}{window}", path("Björk/Post"))).await).await; + let names: Vec = folders(&page).into_iter().map(|folder| folder.0).collect(); + assert_eq!(names, expected_folders, "{window}"); + assert_eq!(titles(&page), expected_tracks, "{window}"); + assert_eq!( + (page["folder_total"].as_i64(), page["track_total"].as_i64()), + (Some(1), Some(2)), + "{window}" + ); + } + + // Received: hashes for names, so artist then title, a missing artist last. + let received = json_body(tree(&owner_token, &path(".waveflow-managed")).await).await; + assert!(folders(&received).is_empty()); + assert_eq!(titles(&received), ["Waterloo", "Zebra", "Nameless"]); + + // Nothing through it for a stranger, and nothing for a folder no + // available track runs through. + for (token, query) in [ + (&stranger_token, String::new()), + (&stranger_token, path("Björk")), + (&owner_token, path("Gone")), + (&owner_token, path("Nowhere")), + (&owner_token, path("Björk/Post/01 Army Of Me.flac")), + ] { + assert_eq!( + tree(token, &query).await.status(), + StatusCode::NOT_FOUND, + "{query}" + ); + } + // One spelling per folder, and a page that means something. + for query in [ + path("Björk/"), + path("/Björk"), + path("Björk//Post"), + path("../Björk"), + "?limit=0".to_owned(), + "?limit=501".to_owned(), + "?offset=-1".to_owned(), + ] { + assert_eq!( + tree(&owner_token, &query).await.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{query}" + ); + } +} diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 316cbe8c..93f06df3 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -3431,14 +3431,16 @@ test("opens the rest of the client from the mobile bar", async ({ const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); - // The eleven the bar cannot hold — including the two this account is + // The twelve the bar cannot hold — including the two this account is // entitled to as an administrator of a library that takes files. `Radio` - // joined them with #217, and this count is what said so: a new entry in the - // sidebar reaches the sheet, and the sheet is pinned. + // joined them with #217, and `Folders` with RFC-012's disk tree; this count + // is what said so: a new entry in the sidebar reaches the sheet, and the + // sheet is pinned. for (const name of [ "Artists", "Songs", "Genres", + "Folders", "Settings", "Random", "Recently played", @@ -3452,7 +3454,7 @@ test("opens the rest of the client from the mobile bar", async ({ } // Counted as well as named: a sheet that also offered something nobody asked // for would satisfy every line above. - await expect(sheet.getByRole("link")).toHaveCount(11); + await expect(sheet.getByRole("link")).toHaveCount(12); await expectNoWcagViolations(page); @@ -4883,3 +4885,125 @@ test("closes the form while a station is saving, and leaves listening alone", as await expect(page.getByLabel("Station name")).toBeEnabled(); await expect(page.getByLabel("Station name")).toHaveValue(""); }); + +/** + * The disk tree (RFC-012 decision 1): one folder at a time, the folder in the + * address, a trail back up, the received folder under its own name, and the + * file names under each title. Read-only: no control here moves anything. + */ +test("walks a library's folders on disk and back up the trail", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + const folder = (name: string, path: string, direct: number, nested: number) => ({ + name, + path, + track_count: direct, + nested_track_count: nested, + }); + const listings: Record = { + "": { + path: "", + folder_total: 2, + track_total: 0, + folders: [ + folder(".waveflow-managed", ".waveflow-managed", 1, 1), + folder("Björk", "Björk", 0, 2), + ], + tracks: [], + }, + Björk: { + path: "Björk", + folder_total: 1, + track_total: 0, + folders: [folder("Vespertine", "Björk/Vespertine", 2, 2)], + tracks: [], + }, + "Björk/Vespertine": { + path: "Björk/Vespertine", + folder_total: 0, + track_total: 2, + folders: [], + tracks: [ + { name: "01 Hidden Place.flac", song: song(1, "Hidden Place", 0, false) }, + { name: "02 Cocoon.flac", song: song(2, "Cocoon", 0, false) }, + ], + }, + ".waveflow-managed": { + path: ".waveflow-managed", + folder_total: 0, + track_total: 1, + folders: [], + tracks: [{ name: "9ad0f9e.flac", song: song(3, "Pagan Poetry", 0, false) }], + }, + }; + const asked: string[] = []; + await page.route("**/api/v2/libraries/library-1/tree?*", (route) => { + const path = new URL(route.request().url()).searchParams.get("path") ?? ""; + asked.push(path); + const listing = listings[path]; + return listing + ? route.fulfill({ json: listing }) + : route.fulfill({ status: 404, json: { error: "not_found" } }); + }); + + await page.goto("/folders"); + await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); + await expect(page.getByText("Nothing here moves a file.", { exact: false })).toBeVisible(); + const folders = page.getByRole("list", { name: "Folders" }); + // The received folder by its label, and an artist folder by what is beneath. + await expect(folders.getByRole("link", { name: /Received/ })).toBeVisible(); + await expect(folders.getByRole("link", { name: /Björk/ })).toContainText("2 tracks"); + await expect(page.getByRole("link", { name: "Upload music" })).toHaveAttribute( + "href", + "/upload", + ); + + await folders.getByRole("link", { name: /Björk/ }).click(); + await expect(page).toHaveURL(/path=Bj%C3%B6rk$/); + await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Vespertine/ }).click(); + await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible(); + const trail = page.getByRole("navigation", { name: "Folder path" }); + await expect(trail.getByRole("link")).toHaveText(["Ma musique", "Björk", "Vespertine"]); + await expect(trail.getByRole("link", { name: "Vespertine" })).toHaveAttribute( + "aria-current", + "page", + ); + // The file's name stands under each title. + const rows = page.locator(".songs tbody tr"); + await expect(rows).toHaveCount(2); + await expect(rows.first()).toContainText("Hidden Place"); + await expect(rows.first()).toContainText("01 Hidden Place.flac"); + await expectNoWcagViolations(page); + + // Back up the trail to the root, then into the received files. + await trail.getByRole("link", { name: "Ma musique" }).click(); + await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); + await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Received/ }).click(); + await expect(page.getByRole("heading", { name: "Received" })).toBeVisible(); + await expect(page.locator(".songs tbody tr")).toContainText("9ad0f9e.flac"); + expect([...new Set(asked)]).toEqual(["", "Björk", "Björk/Vespertine", ".waveflow-managed"]); +}); + +/** A listener walks the same tree and is offered no upload. */ +test("offers a listener the folders without the upload", async ({ page }) => { + libraries = [ + { ...library("library-1", "Ma musique"), role: "listener", accepts_uploads: true }, + ]; + await page.route("**/api/v2/libraries/library-1/tree?*", (route) => + route.fulfill({ + json: { + path: "", + folder_total: 1, + track_total: 0, + folders: [{ name: "Björk", path: "Björk", track_count: 0, nested_track_count: 2 }], + tracks: [], + }, + }), + ); + await page.goto("/folders"); + await expect( + page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Björk/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Upload music" })).toHaveCount(0); +}); diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index af53150e..c28c695c 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -67,13 +67,23 @@ export { setFavorite, setRating, } from "./favorites"; -export type { Library, LibraryMember, ScanJob } from "./libraries"; +export type { + DiskFolder, + DiskListing, + DiskTrack, + Library, + LibraryMember, + ScanJob, +} from "./libraries"; export { addLibrary, + DISK_TREE_PAGE, drainEventStream, + getDiskTree, getScan, listLibraries, listLibraryMembers, + RECEIVED_FOLDER, removeLibraryMember, setLibraryMember, startScan, diff --git a/webapp/src/api/libraries.ts b/webapp/src/api/libraries.ts index f2213563..3aee2613 100644 --- a/webapp/src/api/libraries.ts +++ b/webapp/src/api/libraries.ts @@ -1,3 +1,4 @@ +import type { Song } from "./catalog"; import { call, renewFor, session, sessionGeneration } from "./client"; export type Library = { @@ -21,6 +22,57 @@ export type Library = { export const listLibraries = () => call("/api/v2/libraries"); +/** + * The folder RFC-008 writes received files into, under its real path. The + * client draws it as **Received**; the path is what addresses it. + */ +export const RECEIVED_FOLDER = ".waveflow-managed"; + +export type DiskFolder = { + name: string; + /** Relative to the library, segments joined by `/`. */ + path: string; + /** The tracks directly in it. */ + track_count: number; + /** The tracks anywhere beneath it, its own included. */ + nested_track_count: number; +}; + +/** A track under the name its file has. */ +export type DiskTrack = { name: string; song: Song }; + +/** + * One level of a library's disk tree (RFC-012 decision 1). A page runs over + * the folders, then the tracks; the totals say how long each part is. + */ +export type DiskListing = { + path: string; + folder_total: number; + track_total: number; + folders: DiskFolder[]; + tracks: DiskTrack[]; +}; + +/** How many entries, folders and tracks together, one page carries. */ +export const DISK_TREE_PAGE = 200; + +/** One page of the folder at `path`: empty for the library's root. */ +export const getDiskTree = ( + libraryId: string, + path: string, + offset: number, + limit = DISK_TREE_PAGE, + signal?: AbortSignal, +) => + call( + `/api/v2/libraries/${libraryId}/tree?${new URLSearchParams({ + path, + offset: String(offset), + limit: String(limit), + })}`, + { signal }, + ); + export const addLibrary = ( name: string, path: string, diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index c2aaddec..0cad75a1 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -5,8 +5,10 @@ import { type ArtistDetail, artistTopSongs, canvasUrl, + DISK_TREE_PAGE, getAlbum, getArtist, + getDiskTree, getLyrics, getTrack, getTrackCredits, @@ -208,6 +210,23 @@ export const trackPropertiesQuery = (trackId: string) => }, }); +/** + * One folder of a library's disk tree, a page at a time: folders first, then + * tracks, as the route pages them. Unlike the songs listing this one knows + * its length, so the last page is the one that reaches the totals. + */ +export const diskTreeQuery = (libraryId: string, path: string) => + infiniteQueryOptions({ + queryKey: ["disk-tree", libraryId, path], + queryFn: ({ pageParam, signal }) => + getDiskTree(libraryId, path, pageParam, DISK_TREE_PAGE, signal), + initialPageParam: 0, + getNextPageParam: (last, _pages, lastParam) => + lastParam + DISK_TREE_PAGE < last.folder_total + last.track_total + ? lastParam + DISK_TREE_PAGE + : undefined, + }); + export const lyricsQuery = (trackId: string) => queryOptions({ queryKey: ["lyrics", trackId], diff --git a/webapp/src/components/icons.tsx b/webapp/src/components/icons.tsx index 953fc483..de576c73 100644 --- a/webapp/src/components/icons.tsx +++ b/webapp/src/components/icons.tsx @@ -30,9 +30,14 @@ export type IconName = | "upload" | "radio" | "info" - | "download"; + | "download" + | "folder"; const paths: Record = { + // A folder with its tab: a place the files are, on disk or filed. + folder: ( + + ), // An arrow out of the library onto a tray: the file, saved. download: ( <> diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index a605259d..e4971f79 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -62,10 +62,16 @@ export function SongTable({ songs, numbering = "position", variant = "default", + captions, }: { songs: Song[]; numbering?: "track" | "position"; variant?: "default" | "album"; + /** + * What each row says under its title, in place of the album: the file's + * name, where the screen is about files. + */ + captions?: string[]; }) { const player = usePlayer(); const { t } = useI18n(); @@ -186,7 +192,8 @@ export function SongTable({ {song.title} - {variant === "album" ? song.artist : song.album} + {captions?.[position] ?? + (variant === "album" ? song.artist : song.album)} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 12d4c1f1..5485c1b2 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -58,6 +58,7 @@ import { ArtistPage } from "./pages/artist-page"; import { ArtistsPage } from "./pages/artists-page"; import { AuthorizePage } from "./pages/authorize-page"; import { FavoritesPage } from "./pages/favorites-page"; +import { FoldersPage } from "./pages/folders-page"; import { GenrePage } from "./pages/genre-page"; import { GenresPage } from "./pages/genres-page"; import { HistoryPage } from "./pages/history-page"; @@ -115,6 +116,7 @@ const navigation: Array<{ | "/" | "/artists" | "/songs" + | "/folders" | "/genres" | "/search" | "/favourites" @@ -148,6 +150,12 @@ const navigation: Array<{ { to: "/artists", labelKey: "nav.artists", icon: "artists", group: "pinned" }, { to: "/songs", labelKey: "nav.songs", icon: "songs", group: "pinned" }, { to: "/genres", labelKey: "nav.genres", icon: "genres", group: "library" }, + { + to: "/folders", + labelKey: "nav.folders", + icon: "folder", + group: "library", + }, { to: "/search", labelKey: "nav.search", @@ -288,6 +296,9 @@ async function prepareTab( case "/playing": case "/queue": case "/upload": + // The folder is in the address, and the rail links to the root: the + // page draws its own frame and waits for its folder there. + case "/folders": break; // A fresh draw on every ask, held for no time at all: a draw made here // is thrown away before the page mounts, and the page then draws again. @@ -845,6 +856,21 @@ const adminRoute = createRoute({ component: AdminPage, }); +// The folder is a search parameter rather than a path segment: a folder's +// name may hold any character a file system allows, `$` and `?` included, +// and a parameter carries it whole. +const foldersRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/folders", + validateSearch: (search: Record): { path: string } => ({ + path: typeof search.path === "string" ? search.path : "", + }), + component: function FoldersRoute() { + const { path } = foldersRoute.useSearch(); + return ; + }, +}); + const uploadRoute = createRoute({ getParentRoute: () => authedRoute, path: "/upload", @@ -866,6 +892,7 @@ const routeTree = rootRoute.addChildren([ albumsRoute, albumRoute, songsRoute, + foldersRoute, artistsRoute, artistRoute, genresRoute, diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/folders-page.tsx new file mode 100644 index 00000000..89ec76ab --- /dev/null +++ b/webapp/src/pages/folders-page.tsx @@ -0,0 +1,199 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { type DiskFolder, RECEIVED_FOLDER } from "../api"; +import { diskTreeQuery } from "../api/queries"; +import { Icon } from "../components/icons"; +import { Loading, PageHeader } from "../components/page-shell"; +import { SongTable } from "../components/song-table"; +import { usePlayer } from "../player/player"; +import { useI18n } from "../state/i18n"; +import { mayUploadTo, useLibraryScope } from "../state/library-scope"; + +/** The folder's place in the tree, from the root down, each with its path. */ +function crumbsOf(path: string): Array<{ name: string; path: string }> { + if (!path) return []; + const segments = path.split("/"); + return segments.map((name, index) => ({ + name, + path: segments.slice(0, index + 1).join("/"), + })); +} + +/** + * The active library as its files lie on disk (RFC-012 decision 1). + * + * Read-only, and said so: the tree is the indexed paths grouped, so it has no + * "new folder" and no "move" — those would change the owner's file system. + * The folder RFC-008 receives files into is drawn as **Received**; its path + * stays the real one, which is what the address carries. Upload leads to + * RFC-008's own screen, which decides where a received file goes. + * + * The folder is in the address (`?path=`), so back and forward walk the tree + * and a folder can be linked to. + */ +export function FoldersPage({ path }: { path: string }) { + const { t } = useI18n(); + const player = usePlayer(); + const { active, ready } = useLibraryScope(); + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + } = useInfiniteQuery({ + ...diskTreeQuery(active?.id ?? "", path), + enabled: active !== null, + }); + const folders = useMemo( + () => data?.pages.flatMap((page) => page.folders) ?? [], + [data], + ); + const tracks = useMemo( + () => data?.pages.flatMap((page) => page.tracks) ?? [], + [data], + ); + const songs = useMemo(() => tracks.map((track) => track.song), [tracks]); + const names = useMemo(() => tracks.map((track) => track.name), [tracks]); + const first = data?.pages[0]; + + const label = (folder: Pick) => + folder.path === RECEIVED_FOLDER ? t("folders.received") : folder.name; + const crumbs = crumbsOf(path); + const here = crumbs.at(-1); + const title = here ? label(here) : t("nav.folders"); + + const controls = ( + <> + {songs.length ? ( + + ) : null} + {mayUploadTo(active) ? ( + + + {t("folders.upload")} + + ) : null} + + ); + + const header = ( + + {controls} + + ); + + const breadcrumbs = ( + + ); + + if (ready && !active) { + return ( +
+ {header} +

{t("folders.noLibrary")}

+
+ ); + } + + if (!data) { + return ( +
+ {header} + {breadcrumbs} + +
+ ); + } + + return ( +
+ {header} + {breadcrumbs} +

{t("folders.readOnly")}

+ {folders.length ? ( +
    + {folders.map((folder) => ( +
  • + + + {label(folder)} + + {t("common.tracks", { count: folder.nested_track_count })} + + +
  • + ))} +
+ ) : null} + {songs.length ? : null} + {!folders.length && !songs.length ? ( +

{t("folders.empty")}

+ ) : null} + {hasNextPage && ( +
+ + {isFetchNextPageError ? ( +

+ {t("songs.loadMoreError")} +

+ ) : null} +
+ )} +
+ ); +} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 1be51a87..214704e4 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -441,6 +441,16 @@ const en = { "nav.random": "Random", "nav.playing": "Now playing", "nav.upload": "Upload", + "nav.folders": "Folders", + "folders.received": "Received", + "folders.count": { one: "{count} folder", other: "{count} folders" }, + "folders.where": "Folder path", + "folders.subfolders": "Folders", + "folders.readOnly": + "The library as its files lie on disk, as of the last scan. Nothing here moves a file.", + "folders.upload": "Upload music", + "folders.empty": "No indexed track lives here.", + "folders.noLibrary": "No library is available to this account.", "nav.group.library": "Library", "nav.group.listen": "Listening", "nav.group.account": "Account", @@ -1071,6 +1081,16 @@ const fr: Record = { "nav.random": "Aléatoire", "nav.playing": "En écoute", "nav.upload": "Téléverser", + "nav.folders": "Dossiers", + "folders.received": "Reçus", + "folders.count": { one: "{count} dossier", other: "{count} dossiers" }, + "folders.where": "Chemin du dossier", + "folders.subfolders": "Dossiers", + "folders.readOnly": + "La bibliothèque telle que ses fichiers sont rangés sur le disque, au dernier scan. Rien ici ne déplace un fichier.", + "folders.upload": "Envoyer de la musique", + "folders.empty": "Aucune piste indexée ici.", + "folders.noLibrary": "Aucune bibliothèque n’est accessible à ce compte.", "nav.group.library": "Bibliothèque", "nav.group.listen": "Écoute", "nav.group.account": "Compte", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index 28b9e921..8b3af5e4 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -2860,3 +2860,92 @@ button.danger { color: var(--danger); font-size: var(--text-xs); } + +/* The disk tree (RFC-012). The trail first, each crumb a link back up; the + folder being looked at says so through `aria-current`. */ +.folder-crumbs ol { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin: 0 0 var(--space-3); + padding: 0; + list-style: none; + font-size: var(--text-sm); +} + +.folder-crumbs li + li::before { + margin-right: var(--space-1); + color: var(--muted); + content: "/"; +} + +.folder-crumbs li a { + color: var(--muted); + overflow-wrap: anywhere; +} + +.folder-crumbs li a:hover, +.folder-crumbs li a[aria-current="page"] { + color: var(--text); +} + +.folder-note { + margin: 0 0 var(--space-4); + font-size: var(--text-sm); +} + +.folder-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); + gap: var(--space-2); + margin: 0 0 var(--space-5); + padding: 0; + list-style: none; +} + +.folder-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--panel-soft); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.folder-row:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + background: var(--panel-strong); +} + +.folder-row svg { + grid-row: span 2; + color: var(--accent); +} + +.folder-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A link that leaves for the upload screen, drawn as the buttons beside it. */ +.folder-upload { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 2.55rem; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--line-strong); + border-radius: 0.55rem; + background: var(--panel-strong); +} + +.folder-upload:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--line)); +} From 2df7785d80aceae62ec59d83d81420026029d979 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 09:20:52 +0200 Subject: [PATCH 2/2] fix: say play loaded while a folder has pages left, and test a gap in a folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The play button said 'play all' while more pages were still to load; it now says what it plays, as the songs page does. The disk tree test gains an unavailable track beside available ones in Björk/Post: the counts, totals and pages stay at two. Signed-off-by: InstaZDLL --- tests/native_api.rs | 16 +++++++++++++++- webapp/src/pages/folders-page.tsx | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/native_api.rs b/tests/native_api.rs index d139871d..dee40817 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -4387,13 +4387,18 @@ async fn the_disk_tree_lists_one_level_of_the_indexed_paths_to_members_only() { .create_scan_job(library_id, Some(owner), "manual") .await .unwrap(); - let files: [(&str, &str, Option<&str>); 10] = [ + let files: [(&str, &str, Option<&str>); 11] = [ ( "Björk/Post/02 Hyperballad.flac", "Hyperballad", Some("Björk"), ), ("Björk/Post/01 Army Of Me.flac", "Army Of Me", Some("Björk")), + ( + "Björk/Post/03 Possibly Maybe.flac", + "Possibly Maybe", + Some("Björk"), + ), ("Björk/Post/Bonus/01 Extra.flac", "Extra", Some("Björk")), ("Björk/Homogenic/01 Hunter.flac", "Hunter", Some("Björk")), ("Björk-B/Side.flac", "Side", Some("Other")), @@ -4425,6 +4430,15 @@ async fn the_disk_tree_lists_one_level_of_the_indexed_paths_to_members_only() { .execute(state.db.pool()) .await .unwrap(); + // Nor is it counted in a folder that still has others: `Post` keeps two + // tracks, and the counts, totals and pages below say two, never three. + sqlx::query( + "UPDATE track SET is_available = 0 \ + WHERE relative_path = 'Björk/Post/03 Possibly Maybe.flac'", + ) + .execute(state.db.pool()) + .await + .unwrap(); let router = waveflow_server::app(&config, state.clone()); let owner_token = login_token(&router, "tree-owner", &password).await; diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/folders-page.tsx index 89ec76ab..1e38f1e0 100644 --- a/webapp/src/pages/folders-page.tsx +++ b/webapp/src/pages/folders-page.tsx @@ -75,7 +75,9 @@ export function FoldersPage({ path }: { path: string }) { onClick={() => player.play(songs, 0)} > - {t("songs.playAll", { count: songs.length })} + {hasNextPage + ? t("songs.playLoaded", { count: songs.length }) + : t("songs.playAll", { count: songs.length })} ) : null} {mayUploadTo(active) ? (