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:
Luna 2026-05-25 22:07:42 -07:00
parent 8281246420
commit 4b0e4b3b07
6 changed files with 652 additions and 21 deletions

View file

@ -120,6 +120,10 @@ pub struct App {
/// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from
/// SQLite at startup, mirrored into the DB on every toggle.
flags: crate::database::VideoFlagsBundle,
/// Channel-organisation folders loaded from the `folders` table. The
/// sidebar groups channels by `Channel.folder_id`; channels with no
/// folder fall under "Unfiled".
folders: Vec<crate::database::FolderRecord>,
resume_positions: HashMap<String, f64>,
prev_job_states: HashMap<usize, JobState>,
currently_playing: Option<String>,
@ -155,6 +159,12 @@ pub struct App {
show_channel_options: bool,
/// `(platform, handle)` identifying the channel being edited.
channel_options_target: Option<(Platform, String)>,
// Folder management dialog state.
show_folder_manager: bool,
folder_create_buffer: String,
// Move-to-folder picker state.
show_move_to_folder: bool,
move_to_folder_target: Option<(Platform, String)>,
/// Editable form fields, mirroring [`DownloadOptions`] with string-typed
/// "text" buffers so the user can type partial values (e.g. an empty
/// limit-rate field) without us forcing zeroes back.
@ -216,11 +226,15 @@ impl App {
let db_path = channels_root.join("yt-offline.db");
let db = Database::open(&db_path)
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
// Hydrate per-channel download options from SQLite onto the scanned
// library before publishing it to the UI.
// Hydrate per-channel download options + folder assignments from
// SQLite onto the scanned library before publishing it to the UI.
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 folders = db.list_folders().unwrap_or_default();
let watched = db.get_watched().unwrap_or_default();
let flags = db.get_video_flags().unwrap_or_default();
let resume_positions = db.get_positions().unwrap_or_default();
@ -290,6 +304,7 @@ impl App {
sort_mode: SortMode::Title,
watched,
flags,
folders,
resume_positions,
prev_job_states: HashMap::new(),
currently_playing: None,
@ -316,6 +331,10 @@ impl App {
show_channel_options: false,
channel_options_target: None,
channel_options_form: ChannelOptionsForm::default(),
show_folder_manager: false,
folder_create_buffer: String::new(),
show_move_to_folder: false,
move_to_folder_target: None,
}
}
@ -382,6 +401,10 @@ impl App {
if let Ok(map) = self.db.get_all_channel_options() {
library::apply_channel_options(&mut new_lib, &map);
}
if let Ok(folder_map) = self.db.get_all_channel_assignments() {
library::apply_channel_folders(&mut new_lib, &folder_map);
}
self.folders = self.db.list_folders().unwrap_or_default();
self.library = new_lib;
self.music_library = library::scan_music(&self.music_root);
self.sidebar_view = SidebarView::All;
@ -990,8 +1013,65 @@ impl App {
// the channel index, open the dialog after the loop so we
// don't recursively borrow `self` from inside the closure.
let mut pending_open_options: Option<usize> = None;
let mut pending_move_to_folder: Option<usize> = None;
let mut pending_open_folder_manager = false;
for i in 0..self.library.len() {
// Build an ordered list of (header-or-channel-index) items
// so folders render above platform sections. Channels with
// a folder_id appear only in their folder; unfiled channels
// still get the per-platform grouping.
enum SidebarItem { FolderHeader(String, usize), FolderManageBtn, PlatformHeader(Platform), Channel(usize) }
let mut items: Vec<SidebarItem> = Vec::new();
let has_any_folder_or_assignment = !self.folders.is_empty()
|| self.library.iter().any(|c| c.folder_id.is_some());
if has_any_folder_or_assignment {
items.push(SidebarItem::FolderManageBtn);
for f in &self.folders {
let members: Vec<usize> = self.library.iter().enumerate()
.filter(|(_, c)| c.folder_id == Some(f.id))
.map(|(i, _)| i)
.collect();
items.push(SidebarItem::FolderHeader(f.name.clone(), members.len()));
for i in members { items.push(SidebarItem::Channel(i)); }
}
} else {
items.push(SidebarItem::FolderManageBtn);
}
let mut last_platform_marker: Option<Platform> = None;
for (i, ch) in self.library.iter().enumerate() {
if ch.folder_id.is_some() { continue; }
if Some(ch.platform) != last_platform_marker {
items.push(SidebarItem::PlatformHeader(ch.platform));
last_platform_marker = Some(ch.platform);
}
items.push(SidebarItem::Channel(i));
}
for item in items {
match item {
SidebarItem::FolderManageBtn => {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📁 Folders").small().weak());
if ui.small_button("manage").clicked() {
pending_open_folder_manager = true;
}
});
}
SidebarItem::FolderHeader(name, count) => {
ui.label(
egui::RichText::new(format!(" {name} ({count})"))
.small()
.weak(),
);
}
SidebarItem::PlatformHeader(p) => {
ui.label(
egui::RichText::new(format!("{} {}", p.icon(), p.display_name()))
.small()
.weak(),
);
}
SidebarItem::Channel(i) => {
let (name, total, has_playlists, size_bytes, channel_url, platform) = {
let ch = &self.library[i];
// Prefer the stored `.source-url` over folder-name guessing so
@ -1054,6 +1134,10 @@ impl App {
pending_open_options = Some(channel_idx);
ui.close_menu();
}
if ui.button("📁 Move to folder…").clicked() {
pending_move_to_folder = Some(channel_idx);
ui.close_menu();
}
if ui.button("📁 Open folder").clicked() {
let path = self.library[i].path.clone();
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() {
@ -1078,7 +1162,9 @@ impl App {
}
}
}
}
} // close SidebarItem::Channel arm
} // close match item
} // close for item in items
if let Some(ci) = pending_open_options {
if let Some(ch) = self.library.get(ci) {
@ -1087,6 +1173,16 @@ impl App {
self.show_channel_options = true;
}
}
if let Some(ci) = pending_move_to_folder {
if let Some(ch) = self.library.get(ci) {
self.move_to_folder_target = Some((ch.platform, ch.name.clone()));
self.show_move_to_folder = true;
}
}
if pending_open_folder_manager {
self.show_folder_manager = true;
self.folder_create_buffer.clear();
}
// Process deferred right-click download action. The
// channel's own options + quality override apply for
@ -1659,6 +1755,154 @@ impl App {
}
}
/// Folder management dialog — create / rename / delete folders.
fn folder_manager_window(&mut self, ctx: &egui::Context) {
if !self.show_folder_manager { return; }
let mut open = self.show_folder_manager;
let mut create_clicked = false;
let mut to_rename: Option<(i64, String)> = None;
let mut to_delete: Option<(i64, String, usize)> = None;
egui::Window::new("📁 Manage folders")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(400.0)
.show(ctx, |ui| {
if self.folders.is_empty() {
ui.label(egui::RichText::new("No folders yet — create one below.").weak());
}
let folders = self.folders.clone();
for f in &folders {
let member_count = self.library.iter()
.filter(|c| c.folder_id == Some(f.id))
.count();
ui.horizontal(|ui| {
ui.label(format!("📁 {} ({} channel{})", f.name, member_count, if member_count == 1 { "" } else { "s" }));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("🗑").on_hover_text("Delete folder").clicked() {
to_delete = Some((f.id, f.name.clone(), member_count));
}
if ui.small_button("").on_hover_text("Rename folder").clicked() {
to_rename = Some((f.id, f.name.clone()));
}
});
});
ui.separator();
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.folder_create_buffer)
.hint_text("New folder name")
.desired_width(220.0),
);
if ui.button("Create").clicked() { create_clicked = true; }
});
});
if create_clicked {
let name = self.folder_create_buffer.trim();
if !name.is_empty() {
if let Err(e) = self.db.create_folder(name) {
self.status = format!("Create folder: {e}");
} else {
self.folders = self.db.list_folders().unwrap_or_default();
self.folder_create_buffer.clear();
self.status = "Folder created".to_string();
}
}
}
if let Some((id, old_name)) = to_rename {
// Inline rename: stash the id in a small confirm-style flow.
// For brevity in v1 the rename is handled via a system prompt-free
// approach — replace the existing folder name with the create
// buffer if set, otherwise leave it as-is. (Future iteration:
// proper per-folder edit row.)
let new_name = self.folder_create_buffer.trim().to_string();
if !new_name.is_empty() {
if let Err(e) = self.db.rename_folder(id, &new_name) {
self.status = format!("Rename folder: {e}");
} else {
self.folders = self.db.list_folders().unwrap_or_default();
self.status = format!("Renamed '{old_name}' → '{new_name}'");
self.folder_create_buffer.clear();
}
} else {
self.status = format!("Type new name into the field below, then click ✏ on '{old_name}' again");
}
}
if let Some((id, name, count)) = to_delete {
// Hard delete — member channels revert to Unfiled.
if let Err(e) = self.db.delete_folder(id) {
self.status = format!("Delete folder: {e}");
} else {
for ch in self.library.iter_mut() {
if ch.folder_id == Some(id) { ch.folder_id = None; }
}
self.folders = self.db.list_folders().unwrap_or_default();
self.status = format!("Deleted '{name}' ({count} channel{} unfiled)", if count == 1 { "" } else { "s" });
}
}
self.show_folder_manager = open;
}
/// Move-to-folder picker — small list of all folders + an "Unfiled"
/// row for clearing the assignment.
fn move_to_folder_window(&mut self, ctx: &egui::Context) {
if !self.show_move_to_folder { return; }
let Some((platform, handle)) = self.move_to_folder_target.clone() else {
self.show_move_to_folder = false;
return;
};
let mut open = self.show_move_to_folder;
let mut pick: Option<Option<i64>> = None;
let current = self.library.iter()
.find(|c| c.platform == platform && c.name == handle)
.and_then(|c| c.folder_id);
let folders = self.folders.clone();
egui::Window::new(format!("📁 Move \"{handle}\""))
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(300.0)
.show(ctx, |ui| {
if ui.selectable_label(current.is_none(), "— Unfiled —").clicked() {
pick = Some(None);
}
for f in &folders {
if ui.selectable_label(current == Some(f.id), format!("📁 {}", f.name)).clicked() {
pick = Some(Some(f.id));
}
}
if folders.is_empty() {
ui.label(egui::RichText::new("No folders yet — open Manage folders to create one.").small().weak());
}
});
if let Some(folder_id) = pick {
let platform_dir = platform.dir_name();
match self.db.set_channel_folder(platform_dir, &handle, folder_id) {
Ok(()) => {
for ch in self.library.iter_mut() {
if ch.platform == platform && ch.name == handle {
ch.folder_id = folder_id;
break;
}
}
self.status = match folder_id {
Some(id) => {
let n = self.folders.iter().find(|f| f.id == id).map(|f| f.name.as_str()).unwrap_or("(unknown)");
format!("Moved '{handle}' to '{n}'")
}
None => format!("Moved '{handle}' to Unfiled"),
};
self.show_move_to_folder = false;
}
Err(e) => self.status = format!("Move: {e}"),
}
} else {
self.show_move_to_folder = open;
}
}
fn settings_window(&mut self, ctx: &egui::Context) {
if !self.show_settings {
return;
@ -2678,6 +2922,8 @@ impl eframe::App for App {
self.maintenance_window(ctx);
self.stats_window(ctx);
self.channel_options_window(ctx);
self.folder_manager_window(ctx);
self.move_to_folder_window(ctx);
self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui);