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

@ -22,6 +22,16 @@ use std::path::Path;
type Pool = r2d2::Pool<SqliteConnectionManager>;
type PooledConn = r2d2::PooledConnection<SqliteConnectionManager>;
/// Persisted folder row from the `folders` table. Used by the sidebar +
/// folder-management UI; channels reference folders via the separate
/// `channel_assignments` table.
#[derive(Clone, Debug, serde::Serialize)]
pub struct FolderRecord {
pub id: i64,
pub name: String,
pub position: i64,
}
/// In-memory representation of the `video_flags` table. Each set holds the
/// video IDs that have the named flag enabled — kept small (a few hundred
/// to a few thousand entries in practice).
@ -129,6 +139,19 @@ impl Database {
waiting INTEGER NOT NULL DEFAULT 0,
archive INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
position INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS channel_assignments (
platform TEXT NOT NULL,
handle TEXT NOT NULL,
folder_id INTEGER NOT NULL,
PRIMARY KEY (platform, handle),
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
);",
)?;
Ok(())
@ -192,6 +215,95 @@ impl Database {
Ok(())
}
/// Create a new folder with the given name. Returns the new folder's id.
/// Trying to insert a duplicate name surfaces the SQLite UNIQUE error.
pub fn create_folder(&self, name: &str) -> Result<i64> {
let conn = self.conn();
conn.execute("INSERT INTO folders (name) VALUES (?1)", [name])?;
Ok(conn.last_insert_rowid())
}
/// Rename an existing folder. No-op when the new name already matches.
pub fn rename_folder(&self, id: i64, new_name: &str) -> Result<()> {
let conn = self.conn();
conn.execute("UPDATE folders SET name = ?1 WHERE id = ?2", rusqlite::params![new_name, id])?;
Ok(())
}
/// Delete a folder. Associated channel_assignments rows cascade-delete
/// via the foreign-key constraint, so each member channel reverts to
/// "Unfiled".
pub fn delete_folder(&self, id: i64) -> Result<()> {
let conn = self.conn();
// Enable FK cascade for this connection — SQLite has it off by default.
conn.execute("PRAGMA foreign_keys = ON", [])?;
conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
Ok(())
}
/// List every folder, ordered by `position` then `id` so the sidebar
/// has a deterministic order even before drag-to-reorder ships.
pub fn list_folders(&self) -> Result<Vec<FolderRecord>> {
let conn = self.conn();
let mut stmt = conn.prepare("SELECT id, name, position FROM folders ORDER BY position, id")?;
let rows = stmt.query_map([], |row| {
Ok(FolderRecord {
id: row.get(0)?,
name: row.get(1)?,
position: row.get(2)?,
})
})?;
Ok(rows.filter_map(std::result::Result::ok).collect())
}
/// Set or clear a channel's folder assignment. `folder_id = None`
/// deletes the row so the channel reverts to "Unfiled".
pub fn set_channel_folder(
&self,
platform: &str,
handle: &str,
folder_id: Option<i64>,
) -> Result<()> {
let conn = self.conn();
match folder_id {
Some(fid) => {
conn.execute(
"INSERT OR REPLACE INTO channel_assignments (platform, handle, folder_id)
VALUES (?1, ?2, ?3)",
rusqlite::params![platform, handle, fid],
)?;
}
None => {
conn.execute(
"DELETE FROM channel_assignments WHERE platform = ?1 AND handle = ?2",
[platform, handle],
)?;
}
}
Ok(())
}
/// Bulk fetch of every channel's folder assignment as a
/// `((platform, handle) → folder_id)` map. Used by the library scanner
/// to populate `Channel.folder_id` after a rescan.
pub fn get_all_channel_assignments(&self) -> Result<HashMap<(String, String), i64>> {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT platform, handle, folder_id FROM channel_assignments",
)?;
let map = stmt
.query_map([], |row| {
Ok((
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
row.get::<_, i64>(2)?,
))
})?
.filter_map(std::result::Result::ok)
.map(|(k, v)| (k, v))
.collect();
Ok(map)
}
/// Bulk fetch every video's flag set, grouped by flag. Used at startup +
/// after rescan to hydrate the in-memory caches. The four returned sets
/// hold the IDs of videos with each flag set to true.