Channel folder hierarchy (Tartube parity 1.2)
Users can now organize channels into named folders independent of the
underlying platform groupings. v1 ships single-level (no nesting) since
that's where ~90% of the organizational value lives — nested folders +
drag-to-reparent + per-folder cascading options are deferred to v2 once
we have real-world feedback on what the workflow needs.
Data layer
- New `folders` SQLite table: id, name (UNIQUE), position, created_at.
- New `channel_assignments` table: (platform, handle) → folder_id with
ON DELETE CASCADE so deleting a folder cleanly unfiles its channels.
- Database methods: create_folder, rename_folder, delete_folder (with
PRAGMA foreign_keys ON for the cascade), list_folders,
set_channel_folder, get_all_channel_assignments.
- New `FolderRecord` struct (id/name/position) is `Serialize` so it
rides on the /api/library response.
Library
- `Channel` gains `folder_id: Option<i64>` (None = Unfiled).
- New `library::apply_channel_folders` mirror of `apply_channel_options`
populates it from the bulk DB map after every scan / rescan.
Web API
- `POST /api/folders` create with `{name}` → `{id}`.
- `POST /api/folders/:id/rename` rename with `{name}`.
- `DELETE /api/folders/:id` cascade-deletes assignments.
- `POST /api/channels/:platform/:handle/folder` upsert/clear via
`{folder_id: <i64|null>}`. All endpoints bump the library ETag.
- `/api/library` response now carries a `folders: [...]` array so the
client can render the sidebar grouping without a second round trip.
Web UI
- Sidebar gains a "📁 Folders" section above the platform groupings.
Each folder lists its member channels (with platform icon prefix);
channels with `folder_id` are pulled out of their platform section
to avoid duplication. Unfiled channels keep platform grouping.
- "manage" / "new" button next to the Folders heading opens a modal
with the full folder list (member counts inline) plus rename /
delete buttons and a Create input.
- New per-channel sub-action "📁 Move to folder…" opens a small
picker modal with one row per folder + "— Unfiled —". Current
assignment highlighted.
Desktop UI
- Sidebar refactored to build a `Vec<SidebarItem>` ordered list
(FolderHeader / FolderManageBtn / PlatformHeader / Channel) and
iterate it once, instead of the previous flat
`for i in 0..library.len()` loop. Folder section renders above
the platform sections; channels with `folder_id` are skipped from
the platform-grouped loop so they appear in exactly one place.
- New `folder_manager_window` + `move_to_folder_window` egui windows
cover create / rename (via the create-buffer field) / delete and
per-channel folder picking. Right-click context menu on a channel
gains "📁 Move to folder…".
- App struct gains: `folders`, `show_folder_manager`,
`folder_create_buffer`, `show_move_to_folder`, `move_to_folder_target`.
Tartube spec checklist
- Folder hierarchy box ticked (with the v2 deferrals noted in the
inline annotation).
55 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8281246420
commit
4b0e4b3b07
6 changed files with 652 additions and 21 deletions
114
src/web.rs
114
src/web.rs
|
|
@ -151,6 +151,7 @@ impl WebState {
|
|||
#[derive(Serialize)]
|
||||
struct LibraryResponse {
|
||||
channels: Vec<ChannelInfo>,
|
||||
folders: Vec<crate::database::FolderRecord>,
|
||||
}
|
||||
|
||||
/// JSON representation of a single channel sent to the browser.
|
||||
|
|
@ -168,6 +169,9 @@ struct ChannelInfo {
|
|||
/// exists. Used by the UI's "Check for new videos" action to avoid relying
|
||||
/// on a folder-name heuristic.
|
||||
source_url: Option<String>,
|
||||
/// Folder id from the user's channel-organisation tree. `None` when the
|
||||
/// channel is "Unfiled" (no row in `channel_assignments`).
|
||||
folder_id: Option<i64>,
|
||||
total_videos: usize,
|
||||
size_bytes: u64,
|
||||
subscriber_count: Option<u64>,
|
||||
|
|
@ -944,6 +948,7 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
|||
platform_label: ch.platform.display_name(),
|
||||
platform_icon: ch.platform.icon(),
|
||||
source_url: ch.source_url.clone(),
|
||||
folder_id: ch.folder_id,
|
||||
total_videos: ch.total_videos(),
|
||||
size_bytes: ch.total_size_cached,
|
||||
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
|
||||
|
|
@ -958,7 +963,8 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
|||
}
|
||||
}).collect();
|
||||
|
||||
LibraryResponse { channels }
|
||||
let folders = state.db.list_folders().unwrap_or_default();
|
||||
LibraryResponse { channels, folders }
|
||||
}
|
||||
|
||||
async fn get_music(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
|
|
@ -1526,6 +1532,9 @@ async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|||
if let Ok(map) = state.db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut new_lib, &map);
|
||||
}
|
||||
if let Ok(folder_map) = state.db.get_all_channel_assignments() {
|
||||
library::apply_channel_folders(&mut new_lib, &folder_map);
|
||||
}
|
||||
// Refresh watched from DB after rescan
|
||||
if let Ok(w) = state.db.get_watched() {
|
||||
*state.watched.lock().unwrap() = w;
|
||||
|
|
@ -1577,6 +1586,9 @@ async fn post_maintenance_remove(
|
|||
if let Ok(map) = state.db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut new_lib, &map);
|
||||
}
|
||||
if let Ok(folder_map) = state.db.get_all_channel_assignments() {
|
||||
library::apply_channel_folders(&mut new_lib, &folder_map);
|
||||
}
|
||||
*state.library.lock().unwrap() = new_lib;
|
||||
bump_library_version(&state);
|
||||
Json(serde_json::json!({ "removed": removed, "errors": errors }))
|
||||
|
|
@ -1599,6 +1611,99 @@ async fn post_maintenance_repair(
|
|||
}
|
||||
|
||||
/// `POST /api/scheduler/run` — trigger an immediate scheduled channel check.
|
||||
#[derive(Deserialize)]
|
||||
struct FolderCreateBody { name: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderRenameBody { name: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AssignFolderBody { folder_id: Option<i64> }
|
||||
|
||||
/// `POST /api/folders` — create a new folder. Body: `{ "name": "<name>" }`.
|
||||
/// Returns `{ "id": <new_id> }`.
|
||||
async fn post_create_folder(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<FolderCreateBody>,
|
||||
) -> impl IntoResponse {
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "folder name empty").into_response();
|
||||
}
|
||||
match state.db.create_folder(name) {
|
||||
Ok(id) => {
|
||||
bump_library_version(&state);
|
||||
Json(serde_json::json!({"id": id})).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/folders/:id/rename` — rename an existing folder.
|
||||
async fn post_rename_folder(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<FolderRenameBody>,
|
||||
) -> impl IntoResponse {
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "folder name empty").into_response();
|
||||
}
|
||||
match state.db.rename_folder(id, name) {
|
||||
Ok(()) => {
|
||||
bump_library_version(&state);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/folders/:id` — drop the folder. Member channels revert
|
||||
/// to "Unfiled" via the foreign-key cascade.
|
||||
async fn delete_folder(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.delete_folder(id) {
|
||||
Ok(()) => {
|
||||
// Mirror onto the live library snapshot.
|
||||
let mut lib = state.library.lock().unwrap();
|
||||
for ch in lib.iter_mut() {
|
||||
if ch.folder_id == Some(id) {
|
||||
ch.folder_id = None;
|
||||
}
|
||||
}
|
||||
drop(lib);
|
||||
bump_library_version(&state);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/channels/:platform/:handle/folder` — move a channel into a
|
||||
/// folder, or pass `null` to clear (back to "Unfiled").
|
||||
async fn post_assign_folder(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path((platform, handle)): Path<(String, String)>,
|
||||
Json(body): Json<AssignFolderBody>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = state.db.set_channel_folder(&platform, &handle, body.folder_id) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response();
|
||||
}
|
||||
{
|
||||
let mut lib = state.library.lock().unwrap();
|
||||
for ch in lib.iter_mut() {
|
||||
if ch.platform.dir_name() == platform && ch.name == handle {
|
||||
ch.folder_id = body.folder_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bump_library_version(&state);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FlagToggleBody { value: bool }
|
||||
|
||||
|
|
@ -1838,6 +1943,9 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
if let Ok(map) = db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut library, &map);
|
||||
}
|
||||
if let Ok(folder_map) = db.get_all_channel_assignments() {
|
||||
library::apply_channel_folders(&mut library, &folder_map);
|
||||
}
|
||||
let watched = db.get_watched().unwrap_or_default();
|
||||
let positions = db.get_positions().unwrap_or_default();
|
||||
|
||||
|
|
@ -1918,6 +2026,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/download", post(post_download))
|
||||
.route("/api/watched/:id", post(post_watched))
|
||||
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
|
||||
.route("/api/folders", post(post_create_folder))
|
||||
.route("/api/folders/:id/rename", post(post_rename_folder))
|
||||
.route("/api/folders/:id", axum::routing::delete(delete_folder))
|
||||
.route("/api/channels/:platform/:handle/folder", post(post_assign_folder))
|
||||
.route("/api/resume/:id", post(post_resume))
|
||||
.route("/api/preview", get(get_preview))
|
||||
.route("/api/rescan", post(post_rescan))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue