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

@ -578,8 +578,14 @@ the following are all true.
`channel_options` SQLite table, attached at scan time, applied at `channel_options` SQLite table, attached at scan time, applied at
every dispatch site (scheduled re-checks, right-click checks). every dispatch site (scheduled re-checks, right-click checks).
Editor surfaces in both UIs.* Editor surfaces in both UIs.*
- [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and - [x] **Folder hierarchy** with N-level nesting, drag-to-reparent, and
per-folder default options. per-folder default options. *v1 shipped 2026-05-25: flat folders
(single level, no nesting) via new `folders` + `channel_assignments`
SQLite tables. Sidebar groups assigned channels under their folder;
unfiled channels keep platform grouping. Manage-folders dialog in
both UIs handles create / rename / delete. Per-channel "Move to
folder…" context action. N-level nesting + drag-to-reparent +
per-folder cascading options are deferred to v2.*
- [x] **Smart folders** for Bookmarks / Favourites / Waiting on top of - [x] **Smart folders** for Bookmarks / Favourites / Waiting on top of
Recent + Continue Watching. *v1 shipped 2026-05-25: each smart Recent + Continue Watching. *v1 shipped 2026-05-25: each smart
folder appears in the sidebar only when at least one video carries folder appears in the sidebar only when at least one video carries

View file

@ -120,6 +120,10 @@ pub struct App {
/// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from /// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from
/// SQLite at startup, mirrored into the DB on every toggle. /// SQLite at startup, mirrored into the DB on every toggle.
flags: crate::database::VideoFlagsBundle, 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>, resume_positions: HashMap<String, f64>,
prev_job_states: HashMap<usize, JobState>, prev_job_states: HashMap<usize, JobState>,
currently_playing: Option<String>, currently_playing: Option<String>,
@ -155,6 +159,12 @@ pub struct App {
show_channel_options: bool, show_channel_options: bool,
/// `(platform, handle)` identifying the channel being edited. /// `(platform, handle)` identifying the channel being edited.
channel_options_target: Option<(Platform, String)>, 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 /// Editable form fields, mirroring [`DownloadOptions`] with string-typed
/// "text" buffers so the user can type partial values (e.g. an empty /// "text" buffers so the user can type partial values (e.g. an empty
/// limit-rate field) without us forcing zeroes back. /// 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_path = channels_root.join("yt-offline.db");
let db = Database::open(&db_path) let db = Database::open(&db_path)
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
// Hydrate per-channel download options from SQLite onto the scanned // Hydrate per-channel download options + folder assignments from
// library before publishing it to the UI. // SQLite onto the scanned library before publishing it to the UI.
if let Ok(map) = db.get_all_channel_options() { if let Ok(map) = db.get_all_channel_options() {
library::apply_channel_options(&mut library, &map); 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 watched = db.get_watched().unwrap_or_default();
let flags = db.get_video_flags().unwrap_or_default(); let flags = db.get_video_flags().unwrap_or_default();
let resume_positions = db.get_positions().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default();
@ -290,6 +304,7 @@ impl App {
sort_mode: SortMode::Title, sort_mode: SortMode::Title,
watched, watched,
flags, flags,
folders,
resume_positions, resume_positions,
prev_job_states: HashMap::new(), prev_job_states: HashMap::new(),
currently_playing: None, currently_playing: None,
@ -316,6 +331,10 @@ impl App {
show_channel_options: false, show_channel_options: false,
channel_options_target: None, channel_options_target: None,
channel_options_form: ChannelOptionsForm::default(), 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() { if let Ok(map) = self.db.get_all_channel_options() {
library::apply_channel_options(&mut new_lib, &map); 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.library = new_lib;
self.music_library = library::scan_music(&self.music_root); self.music_library = library::scan_music(&self.music_root);
self.sidebar_view = SidebarView::All; self.sidebar_view = SidebarView::All;
@ -990,8 +1013,65 @@ impl App {
// the channel index, open the dialog after the loop so we // the channel index, open the dialog after the loop so we
// don't recursively borrow `self` from inside the closure. // don't recursively borrow `self` from inside the closure.
let mut pending_open_options: Option<usize> = None; 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 (name, total, has_playlists, size_bytes, channel_url, platform) = {
let ch = &self.library[i]; let ch = &self.library[i];
// Prefer the stored `.source-url` over folder-name guessing so // Prefer the stored `.source-url` over folder-name guessing so
@ -1054,6 +1134,10 @@ impl App {
pending_open_options = Some(channel_idx); pending_open_options = Some(channel_idx);
ui.close_menu(); 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() { if ui.button("📁 Open folder").clicked() {
let path = self.library[i].path.clone(); let path = self.library[i].path.clone();
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() { 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(ci) = pending_open_options {
if let Some(ch) = self.library.get(ci) { if let Some(ch) = self.library.get(ci) {
@ -1087,6 +1173,16 @@ impl App {
self.show_channel_options = true; 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 // Process deferred right-click download action. The
// channel's own options + quality override apply for // 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) { fn settings_window(&mut self, ctx: &egui::Context) {
if !self.show_settings { if !self.show_settings {
return; return;
@ -2678,6 +2922,8 @@ impl eframe::App for App {
self.maintenance_window(ctx); self.maintenance_window(ctx);
self.stats_window(ctx); self.stats_window(ctx);
self.channel_options_window(ctx); self.channel_options_window(ctx);
self.folder_manager_window(ctx);
self.move_to_folder_window(ctx);
self.detail_panel(ctx); self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui); self.video_list(ctx, ui);

View file

@ -22,6 +22,16 @@ use std::path::Path;
type Pool = r2d2::Pool<SqliteConnectionManager>; type Pool = r2d2::Pool<SqliteConnectionManager>;
type PooledConn = r2d2::PooledConnection<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 /// 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 /// video IDs that have the named flag enabled — kept small (a few hundred
/// to a few thousand entries in practice). /// to a few thousand entries in practice).
@ -129,6 +139,19 @@ impl Database {
waiting INTEGER NOT NULL DEFAULT 0, waiting INTEGER NOT NULL DEFAULT 0,
archive INTEGER NOT NULL DEFAULT 0, archive INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 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(()) Ok(())
@ -192,6 +215,95 @@ impl Database {
Ok(()) 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 + /// 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 /// after rescan to hydrate the in-memory caches. The four returned sets
/// hold the IDs of videos with each flag set to true. /// hold the IDs of videos with each flag set to true.

View file

@ -124,6 +124,11 @@ pub struct Channel {
/// scan by reading the DB once via /// scan by reading the DB once via
/// [`crate::database::Database::get_all_channel_options`]. /// [`crate::database::Database::get_all_channel_options`].
pub download_options: DownloadOptions, 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 { 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`] /// Mutate `channels` in place, filling each one's [`Channel::download_options`]
/// from the supplied `(platform_dir_name, handle) → options_json` map. /// 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_videos_cached,
total_size_cached, total_size_cached,
download_options: DownloadOptions::default(), download_options: DownloadOptions::default(),
folder_id: None,
}) })
}) })
.into_iter() .into_iter()

View file

@ -151,6 +151,7 @@ impl WebState {
#[derive(Serialize)] #[derive(Serialize)]
struct LibraryResponse { struct LibraryResponse {
channels: Vec<ChannelInfo>, channels: Vec<ChannelInfo>,
folders: Vec<crate::database::FolderRecord>,
} }
/// JSON representation of a single channel sent to the browser. /// 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 /// exists. Used by the UI's "Check for new videos" action to avoid relying
/// on a folder-name heuristic. /// on a folder-name heuristic.
source_url: Option<String>, 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, total_videos: usize,
size_bytes: u64, size_bytes: u64,
subscriber_count: Option<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_label: ch.platform.display_name(),
platform_icon: ch.platform.icon(), platform_icon: ch.platform.icon(),
source_url: ch.source_url.clone(), source_url: ch.source_url.clone(),
folder_id: ch.folder_id,
total_videos: ch.total_videos(), total_videos: ch.total_videos(),
size_bytes: ch.total_size_cached, size_bytes: ch.total_size_cached,
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count), 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(); }).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 { 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() { if let Ok(map) = state.db.get_all_channel_options() {
library::apply_channel_options(&mut new_lib, &map); 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 // Refresh watched from DB after rescan
if let Ok(w) = state.db.get_watched() { if let Ok(w) = state.db.get_watched() {
*state.watched.lock().unwrap() = w; *state.watched.lock().unwrap() = w;
@ -1577,6 +1586,9 @@ async fn post_maintenance_remove(
if let Ok(map) = state.db.get_all_channel_options() { if let Ok(map) = state.db.get_all_channel_options() {
library::apply_channel_options(&mut new_lib, &map); 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; *state.library.lock().unwrap() = new_lib;
bump_library_version(&state); bump_library_version(&state);
Json(serde_json::json!({ "removed": removed, "errors": errors })) 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. /// `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)] #[derive(Deserialize)]
struct FlagToggleBody { value: bool } 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() { if let Ok(map) = db.get_all_channel_options() {
library::apply_channel_options(&mut library, &map); 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 watched = db.get_watched().unwrap_or_default();
let positions = db.get_positions().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/download", post(post_download))
.route("/api/watched/:id", post(post_watched)) .route("/api/watched/:id", post(post_watched))
.route("/api/videos/:id/flags/:flag", post(post_video_flag)) .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/resume/:id", post(post_resume))
.route("/api/preview", get(get_preview)) .route("/api/preview", get(get_preview))
.route("/api/rescan", post(post_rescan)) .route("/api/rescan", post(post_rescan))

View file

@ -174,6 +174,9 @@
<script> <script>
'use strict'; 'use strict';
let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, showContinue=false, showChannels=false, showMusic=false, showRecent=false; let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, showContinue=false, showChannels=false, showMusic=false, showRecent=false;
// Folder list from the most-recent /api/library response; populated by
// loadLibrary() and read by renderSidebar() to draw the Folders group.
let librarySnapshotFolders=[];
// Smart-folder view selectors — at most one is true at a time. // Smart-folder view selectors — at most one is true at a time.
let showFavourites=false, showBookmarks=false, showWaiting=false; let showFavourites=false, showBookmarks=false, showWaiting=false;
let musicTracks=[]; let musicTracks=[];
@ -204,6 +207,7 @@ async function loadLibrary(){
libraryEtag=r.headers.get('ETag')||libraryEtag; libraryEtag=r.headers.get('ETag')||libraryEtag;
const data=await r.json(); const data=await r.json();
library=data.channels; library=data.channels;
librarySnapshotFolders=data.folders||[];
channelUrls=library.map(ch=>ch.channel_url||null); channelUrls=library.map(ch=>ch.channel_url||null);
const total=library.reduce((s,c)=>s+c.size_bytes,0); const total=library.reduce((s,c)=>s+c.size_bytes,0);
document.getElementById('hdr-stats').textContent=total>0?fmtSize(total)+' total':''; document.getElementById('hdr-stats').textContent=total>0?fmtSize(total)+' total':'';
@ -234,26 +238,64 @@ function renderSidebar(){
h+=`<div class="ch-item${showChannels?' active':''}" onclick="setChannels()">⊟ Channels (${library.length})</div>`; h+=`<div class="ch-item${showChannels?' active':''}" onclick="setChannels()">⊟ Channels (${library.length})</div>`;
h+=`<div class="ch-item${!showContinue&&!showChannels&&!showRecent&&!anySmart&&activeChannelIdx===null&&!showMusic?' active':''}" onclick="setView(null,null)">⊞ All (${total})</div>`; h+=`<div class="ch-item${!showContinue&&!showChannels&&!showRecent&&!anySmart&&activeChannelIdx===null&&!showMusic?' active':''}" onclick="setView(null,null)">⊞ All (${total})</div>`;
h+=`<div class="ch-item${showMusic?' active':''}" onclick="setMusic()">♫ Music (${musicTracks.length})</div>`; h+=`<div class="ch-item${showMusic?' active':''}" onclick="setMusic()">♫ Music (${musicTracks.length})</div>`;
// Group sidebar entries by platform so a multi-platform library reads as
// distinct sections rather than one flat list. // Render a single channel entry (collapsed or expanded with sub-actions).
let lastPlatform=null; // Returned HTML is appended at the right point — used both in the
// folder loop and in the per-platform Unfiled loop below.
const renderChannel=(i)=>{
const ch=library[i];
const active=activeChannelIdx===i&&activePlaylistIdx===null&&!showContinue;
const size=ch.size_bytes>0?' · '+fmtSize(ch.size_bytes):'';
let s=`<div class="ch-item${active?' active':''}" onclick="setView(${i},null)" title="${esc(ch.platform_label||'')}">${esc(ch.platform_icon||'')} ${esc(ch.name)} (${ch.total_videos}${size})</div>`;
if(activeChannelIdx===i&&!showContinue){
for(let pi=0;pi<ch.playlists.length;pi++){
const pl=ch.playlists[pi];
s+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
}
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
}
return s;
};
// User-defined folders (from the `folders` SQLite table). Channels with
// a `folder_id` matching the row land here. Folders override platform
// grouping for their members; Unfiled channels keep the platform
// sections below.
const folders=Array.isArray(librarySnapshotFolders)?librarySnapshotFolders:[];
if(folders.length){
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">manage</button>
</div>`;
for(const f of folders){
const members=[];
for(let i=0;i<library.length;i++){ for(let i=0;i<library.length;i++){
if(library[i].folder_id===f.id)members.push(i);
}
const count=members.length;
h+=`<div class="sidebar-label" style="padding-left:8px;font-size:11px;color:var(--muted)">${esc(f.name)} (${count})</div>`;
for(const i of members)h+=renderChannel(i);
}
} else {
h+=`<div class="sidebar-label" style="margin-top:10px;display:flex;align-items:center;gap:6px">
📁 Folders
<button onclick="openFolderManager()" style="font-size:11px;padding:1px 6px;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;cursor:pointer">new</button>
</div>`;
}
// Channels not assigned to a folder still get platform grouping.
const unfiled=[];
for(let i=0;i<library.length;i++)if(!library[i].folder_id)unfiled.push(i);
let lastPlatform=null;
for(const i of unfiled){
const ch=library[i]; const ch=library[i];
if(ch.platform!==lastPlatform){ if(ch.platform!==lastPlatform){
h+=`<div class="sidebar-label" style="margin-top:8px">${esc(ch.platform_icon||'')} ${esc(ch.platform_label||'Channels')}</div>`; h+=`<div class="sidebar-label" style="margin-top:8px">${esc(ch.platform_icon||'')} ${esc(ch.platform_label||'Channels')}</div>`;
lastPlatform=ch.platform; lastPlatform=ch.platform;
} }
const active=activeChannelIdx===i&&activePlaylistIdx===null&&!showContinue; h+=renderChannel(i);
const size=ch.size_bytes>0?' · '+fmtSize(ch.size_bytes):'';
h+=`<div class="ch-item${active?' active':''}" onclick="setView(${i},null)" title="${esc(ch.platform_label||'')}">${esc(ch.name)} (${ch.total_videos}${size})</div>`;
if(activeChannelIdx===i&&!showContinue){
for(let pi=0;pi<ch.playlists.length;pi++){
const pl=ch.playlists[pi];
h+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
}
h+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
h+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
}
} }
el.innerHTML=h; el.innerHTML=h;
} }
@ -640,6 +682,101 @@ async function clearChannelOptions(platform,handle,btn){
}catch(e){setStatus('Error: '+e.message);btn.disabled=false} }catch(e){setStatus('Error: '+e.message);btn.disabled=false}
} }
/* ── Folder management ─────────────────────────────────────────── */
async function openFolderManager(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const folders=librarySnapshotFolders||[];
const rows=folders.map(f=>{
const memberCount=library.filter(c=>c.folder_id===f.id).length;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<span style="flex:1">📁 ${esc(f.name)} <span style="color:var(--muted);font-size:11px">(${memberCount} channel${memberCount===1?'':'s'})</span></span>
<button onclick="renameFolderPrompt(${f.id},'${esc(f.name)}')">Rename</button>
<button onclick="deleteFolderConfirm(${f.id},'${esc(f.name)}',${memberCount})" style="color:#f87171">Delete</button>
</div>`;
}).join('');
bg.innerHTML=`<div class="modal" style="max-width:480px;width:100%">
<div class="modal-hdr"><h2>📁 Manage folders</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div style="margin-bottom:12px">
${rows||'<div style="color:var(--muted);font-size:12px;padding:4px 0">No folders yet — create one below to organize your channels.</div>'}
</div>
<div style="display:flex;gap:6px">
<input type="text" id="new-folder-name" placeholder="New folder name" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px">
<button class="primary" onclick="createFolder(this)">Create</button>
</div>
</div>`;
document.body.appendChild(bg);
}
async function createFolder(btn){
const name=(document.getElementById('new-folder-name')?.value||'').trim();
if(!name){setStatus('Folder name required');return}
btn.disabled=true;
try{
const r=await api('/api/folders',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder created');
btn.closest('.modal-bg').remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
async function renameFolderPrompt(id,oldName){
const name=window.prompt('Rename folder',oldName);
if(!name||name===oldName)return;
try{
const r=await api(`/api/folders/${id}/rename`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder renamed');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
async function deleteFolderConfirm(id,name,memberCount){
const msg=memberCount>0
? `Delete folder "${name}"? Its ${memberCount} channel${memberCount===1?'':'s'} will revert to "Unfiled".`
: `Delete folder "${name}"?`;
if(!confirm(msg))return;
try{
const r=await api(`/api/folders/${id}`,{method:'DELETE'});
if(!r.ok)throw new Error(await r.text());
setStatus('Folder deleted');
document.querySelector('.modal-bg')?.remove();
await loadLibrary();
openFolderManager();
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Move channel to folder ────────────────────────────────────── */
async function openMoveToFolder(channelIdx){
const ch=library[channelIdx]; if(!ch)return;
const folders=librarySnapshotFolders||[];
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
const options=[
`<button onclick="moveChannelToFolder(${channelIdx},null,this)" style="text-align:left;padding:8px 12px;width:100%;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--text);cursor:pointer${ch.folder_id===null?';background:var(--card)':''}">— Unfiled —</button>`,
...folders.map(f=>`<button onclick="moveChannelToFolder(${channelIdx},${f.id},this)" style="text-align:left;padding:8px 12px;width:100%;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--text);cursor:pointer${ch.folder_id===f.id?';background:var(--card)':''}">📁 ${esc(f.name)}</button>`),
].join('');
bg.innerHTML=`<div class="modal" style="max-width:360px;width:100%">
<div class="modal-hdr"><h2>Move "${esc(ch.name)}"</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div style="display:flex;flex-direction:column;gap:6px">${options}</div>
<div class="settings-hint" style="margin-top:10px">Channels in a folder appear under that folder instead of their platform section.</div>
</div>`;
document.body.appendChild(bg);
}
async function moveChannelToFolder(channelIdx,folderId,btn){
const ch=library[channelIdx]; if(!ch)return;
btn.disabled=true;
try{
const r=await api(`/api/channels/${encodeURIComponent(ch.platform)}/${encodeURIComponent(ch.name)}/folder`,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({folder_id:folderId}),
});
if(!r.ok)throw new Error(await r.text());
btn.closest('.modal-bg').remove();
setStatus('Channel moved');
await loadLibrary();
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
/* ── Rescan ─────────────────────────────────────────────────────── */ /* ── Rescan ─────────────────────────────────────────────────────── */
async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}} async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}