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
|
|
@ -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
|
||||||
|
|
|
||||||
254
src/app.rs
254
src/app.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
112
src/database.rs
112
src/database.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
114
src/web.rs
114
src/web.rs
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
// 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++){
|
||||||
|
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;
|
let lastPlatform=null;
|
||||||
for(let i=0;i<library.length;i++){
|
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)}}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue