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

@ -124,6 +124,11 @@ pub struct Channel {
/// scan by reading the DB once via
/// [`crate::database::Database::get_all_channel_options`].
pub download_options: DownloadOptions,
/// Optional folder grouping. `None` means the channel is "Unfiled" and
/// appears under its platform's heading. Populated post-scan by
/// [`apply_channel_folders`] from the
/// [`crate::database::Database::get_all_channel_assignments`] map.
pub folder_id: Option<i64>,
}
impl Channel {
@ -140,6 +145,18 @@ impl Channel {
}
}
/// Mutate `channels` in place, filling each one's [`Channel::folder_id`]
/// from the supplied `(platform_dir_name, handle) → folder_id` map.
pub fn apply_channel_folders(
channels: &mut [Channel],
folder_map: &std::collections::HashMap<(String, String), i64>,
) {
for ch in channels {
let key = (ch.platform.dir_name().to_string(), ch.name.clone());
ch.folder_id = folder_map.get(&key).copied();
}
}
/// Mutate `channels` in place, filling each one's [`Channel::download_options`]
/// from the supplied `(platform_dir_name, handle) → options_json` map.
///
@ -222,6 +239,7 @@ pub fn scan_channels(youtube_root: &Path) -> Vec<Channel> {
total_videos_cached,
total_size_cached,
download_options: DownloadOptions::default(),
folder_id: None,
})
})
.into_iter()