From 82812464202377e2a0234e0f54c09b7ea91b7e8f Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 25 May 2026 21:57:52 -0700 Subject: [PATCH] Per-video state flags + smart folders + comments capture (Tartube parity 1.3/1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Tartube-parity items shipped together since they touch the same schema and rendering paths. Per-video state flags - New `video_flags` SQLite table with `bookmark`/`favourite`/`waiting`/ `archive` columns (one row per video that has at least one flag set). - `Database::set_video_flag` validates the flag name against an allow-list before interpolating into the SQL β€” column names can't be parameterised in SQLite, but the allow-list keeps it injection-free. - `Database::get_video_flags` bulk-loads into a new `VideoFlagsBundle { bookmark, favourite, waiting, archive: HashSet }` so subsequent reads are O(1) in-memory hits. - `WebState.flags` (Mutex) + `App.flags` (VideoFlagsBundle, GUI is single-threaded) populated at startup. - `POST /api/videos/:id/flags/:flag` toggles a flag; the in-memory bundle is mirrored immediately so the next /api/library reflects the change without a rescan. Library ETag is bumped. - `VideoInfo` JSON gains `bookmark` / `favourite` / `waiting` / `archive` boolean fields. - `archive` is wired through everywhere but no UI gate yet β€” it's reserved for the future auto-delete feature. Smart folders - Both UIs gain "β˜… Favourites", "πŸ”– Bookmarks", "⏳ Waiting" sidebar entries between Continue Watching and Channels. Each is hidden when its set is empty, so a fresh install isn't polluted with zero-count rows. - Desktop: new `SidebarView::Favourites / Bookmarks / Waiting` variants. `compute_cards` filters the library by the matching `self.flags.` HashSet and returns the resulting cards using the same rendering path as the other views. - Web: `showFavourites/Bookmarks/Waiting` flags + smart-folder branch in `currentVideos()`. Refactored the view-mode selectors through a new `resetViewSelectors()` helper so adding three more booleans didn't quadruple every `setX` function. Per-card flag toggles - Web: each video card grows three new action buttons (β˜†/β˜… favourite, πŸ”– bookmark, ⏳ waiting) next to the existing watched toggle. Optimistic UI β€” flip in-memory immediately, undo on server error. - Desktop: same three buttons next to Watched in the card row, deferred through a `toggle_flag_card: Option<&'static str>` to keep the borrow checker happy. New `App::toggle_video_flag` helper persists the flip to SQLite and invalidates the card cache. Comments capture - `DownloadOptions` gains `fetch_comments: bool`. When set, the downloader emits `--write-comments` so yt-dlp embeds the full comment tree into info.json. - New `GET /api/comments/:id` reads the array out of info.json and returns a slimmed-down version (id, author, text, likes, parent, time) β€” strips the multi-MB extras yt-dlp can otherwise inline. - Web player modal grows a πŸ’¬ button that opens a separate modal with a threaded viewer: replies indented by parent id, "N comments" header, helpful message when nothing's captured pointing at the channel-options toggle. - Channel-options dialog in both UIs gains a "Fetch comments" checkbox + hint about the slowdown. Tartube spec checklist - Three more boxes ticked: per-video state flags, smart folders, comments capture. Score now: 5 ahead, 5 behind, 1 tied + matching on 4 parity items. (Half done.) 55 unit tests pass. Co-Authored-By: Claude Opus 4.7 --- docs/tartube-spec.md | 23 +++++--- src/app.rs | 121 ++++++++++++++++++++++++++++++++++++++++ src/database.rs | 71 +++++++++++++++++++++++ src/download_options.rs | 10 ++++ src/web.rs | 107 +++++++++++++++++++++++++++++++++++ src/web_ui/index.html | 120 ++++++++++++++++++++++++++++++++++++--- 6 files changed, 437 insertions(+), 15 deletions(-) diff --git a/docs/tartube-spec.md b/docs/tartube-spec.md index 2fe8dfc..20bb057 100644 --- a/docs/tartube-spec.md +++ b/docs/tartube-spec.md @@ -580,13 +580,22 @@ the following are all true. Editor surfaces in both UIs.* - [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and per-folder default options. -- [ ] **Smart folders** for Bookmarks / Favourites / Waiting / New / - Live / Missing, on top of our existing Recent + Continue - Watching. -- [ ] **Per-video state flags** persisted (extending the SQLite - schema): `bookmark`, `favourite`, `archive`, `waiting`, - `block`, plus a `missing` derived flag from the maintenance - scan. +- [x] **Smart folders** for Bookmarks / Favourites / Waiting on top of + Recent + Continue Watching. *v1 shipped 2026-05-25: each smart + folder appears in the sidebar only when at least one video carries + the matching flag, so a fresh install isn't polluted with empty + rows. Driven by the in-memory flag bundle so no SQL per render.* +- [x] **Per-video state flags** persisted in a new `video_flags` + SQLite table with columns `bookmark`, `favourite`, `waiting`, + `archive`. Loaded as four `HashSet` at startup, mutated + via `POST /api/videos/:id/flags/:flag` (web) or the per-card + action buttons (desktop). `archive` is wired through but UI is + deferred until we actually have auto-deletion to gate. +- [x] **Comments capture** via the new `fetch_comments` field in + DownloadOptions. Adds `--write-comments` to the yt-dlp command + when set. New `GET /api/comments/:id` reads the comments array + out of info.json and the web player modal's πŸ’¬ button renders a + threaded viewer. - [ ] **Profiles** β€” saved named groups of channels with a "Download this profile" action. - [ ] **Tidy + Refresh** unified into a single bidirectional sync diff --git a/src/app.rs b/src/app.rs index 1580f60..09e6d8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -49,6 +49,12 @@ enum SidebarView { ContinueWatching, /// Activity feed β€” recent additions across all channels, sorted by mtime. Recent, + /// Smart folder: videos with `favourite` flag set. + Favourites, + /// Smart folder: videos with `bookmark` flag set. + Bookmarks, + /// Smart folder: videos with `waiting` flag set. + Waiting, Music, } @@ -64,6 +70,9 @@ struct Card { upload_date: Option, mtime_unix: Option, watched: bool, + bookmark: bool, + favourite: bool, + waiting: bool, resume_pos: Option, } @@ -108,6 +117,9 @@ pub struct App { card_density: f32, sort_mode: SortMode, watched: HashSet, + /// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from + /// SQLite at startup, mirrored into the DB on every toggle. + flags: crate::database::VideoFlagsBundle, resume_positions: HashMap, prev_job_states: HashMap, currently_playing: Option, @@ -156,6 +168,7 @@ pub struct App { struct ChannelOptionsForm { quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p audio_only: bool, + fetch_comments: bool, limit_rate_kb: String, min_filesize_mb: String, max_filesize_mb: String, @@ -209,6 +222,7 @@ impl App { library::apply_channel_options(&mut library, &map); } let watched = db.get_watched().unwrap_or_default(); + let flags = db.get_video_flags().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default(); let music_root = channels_root.with_file_name("music"); @@ -275,6 +289,7 @@ impl App { card_density: 1.0, sort_mode: SortMode::Title, watched, + flags, resume_positions, prev_job_states: HashMap::new(), currently_playing: None, @@ -319,6 +334,7 @@ impl App { ChannelOptionsForm { quality_idx, audio_only: opts.audio_only, + fetch_comments: opts.fetch_comments, limit_rate_kb: num(opts.limit_rate_kb), min_filesize_mb: num(opts.min_filesize_mb), max_filesize_mb: num(opts.max_filesize_mb), @@ -348,6 +364,7 @@ impl App { crate::download_options::DownloadOptions { quality, audio_only: f.audio_only, + fetch_comments: f.fetch_comments, limit_rate_kb: parse_num(&f.limit_rate_kb), min_filesize_mb: parse_num(&f.min_filesize_mb), max_filesize_mb: parse_num(&f.max_filesize_mb), @@ -430,6 +447,9 @@ impl App { upload_date: v.upload_date.clone(), mtime_unix: v.mtime_unix, watched: self.watched.contains(&v.id), + bookmark: self.flags.bookmark.contains(&v.id), + favourite: self.flags.favourite.contains(&v.id), + waiting: self.flags.waiting.contains(&v.id), resume_pos, }); }; @@ -466,6 +486,24 @@ impl App { cards.truncate(100); return cards; } + SidebarView::Favourites | SidebarView::Bookmarks | SidebarView::Waiting => { + // Smart folders. Filter the whole library by the matching + // flag bundle set. + let set = match self.sidebar_view { + SidebarView::Favourites => &self.flags.favourite, + SidebarView::Bookmarks => &self.flags.bookmark, + SidebarView::Waiting => &self.flags.waiting, + _ => unreachable!("outer match guarantees the variant"), + }; + for ch in &self.library { + for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) { + if set.contains(&v.id) { + add_video(&mut cards, &ch.name, v); + } + } + } + return cards; + } SidebarView::All => { for ch in &self.library { for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) { @@ -637,6 +675,28 @@ impl App { } } + /// Flip a per-video flag (`"bookmark"`, `"favourite"`, `"waiting"`, or + /// `"archive"`) and persist the change. Unknown flag names are ignored + /// rather than panic so a typo surfaces in the UI as a no-op. + fn toggle_video_flag(&mut self, video_id: &str, flag: &'static str) { + let set = match flag { + "bookmark" => &mut self.flags.bookmark, + "favourite" => &mut self.flags.favourite, + "waiting" => &mut self.flags.waiting, + "archive" => &mut self.flags.archive, + _ => return, + }; + let now_set = !set.contains(video_id); + if let Err(e) = self.db.set_video_flag(video_id, flag, now_set) { + self.status = format!("Flag {flag}: {e}"); + return; + } + if now_set { set.insert(video_id.to_string()); } else { set.remove(video_id); } + // Bust the cards cache so the action icons + smart-folder counts + // refresh on the next paint. + self.cards_cache_key = None; + } + fn start_web_server(&mut self) { if self.web_server_running { return; @@ -873,6 +933,43 @@ impl App { self.selected_video = None; } + // Smart-folder sidebar entries β€” each shown only when at + // least one video carries the matching flag. Keeps a + // brand-new install free of empty rows. + let fav_count = self.flags.favourite.len(); + if fav_count > 0 && ui + .selectable_label( + self.sidebar_view == SidebarView::Favourites, + format!("β˜… Favourites ({fav_count})"), + ) + .clicked() + { + self.sidebar_view = SidebarView::Favourites; + self.selected_video = None; + } + let bmk_count = self.flags.bookmark.len(); + if bmk_count > 0 && ui + .selectable_label( + self.sidebar_view == SidebarView::Bookmarks, + format!("πŸ”– Bookmarks ({bmk_count})"), + ) + .clicked() + { + self.sidebar_view = SidebarView::Bookmarks; + self.selected_video = None; + } + let wait_count = self.flags.waiting.len(); + if wait_count > 0 && ui + .selectable_label( + self.sidebar_view == SidebarView::Waiting, + format!("⏳ Waiting ({wait_count})"), + ) + .clicked() + { + self.sidebar_view = SidebarView::Waiting; + self.selected_video = None; + } + let music_count = self.music_library.len(); if ui .selectable_label( @@ -1453,6 +1550,11 @@ impl App { ui.checkbox(&mut form.audio_only, "Extract audio (best format)"); ui.end_row(); + ui.label("Fetch comments"); + ui.checkbox(&mut form.fetch_comments, "Embed --write-comments into info.json") + .on_hover_text("Slow on popular videos. Once captured, comments are browsable from the player modal in the web UI."); + ui.end_row(); + ui.label("Bandwidth cap (KB/s)"); ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off")); ui.end_row(); @@ -2308,6 +2410,9 @@ impl App { let mut clicked_card = false; let mut play_card = false; let mut toggle_watched_card = false; + // Flag toggles deferred outside the row closure so we can + // mutate `self.flags` + DB without fighting the borrow checker. + let mut toggle_flag_card: Option<&'static str> = None; ui.horizontal(|ui| { let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); @@ -2446,6 +2551,18 @@ impl App { if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() { toggle_watched_card = true; } + let fav_label = if card.favourite { "β˜…" } else { "β˜†" }; + if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() { + toggle_flag_card = Some("favourite"); + } + let bm_label = if card.bookmark { "πŸ”–" } else { "πŸ”–Μ²" }; + if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() { + toggle_flag_card = Some("bookmark"); + } + let wait_label = if card.waiting { "⏳" } else { "⏰" }; + if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() { + toggle_flag_card = Some("waiting"); + } } }); }); @@ -2473,6 +2590,10 @@ impl App { let id = card.id.clone(); self.toggle_watched(&id); } + if let Some(flag) = toggle_flag_card { + let id = card.id.clone(); + self.toggle_video_flag(&id, flag); + } ui.separator(); } diff --git a/src/database.rs b/src/database.rs index e60459f..ce06601 100644 --- a/src/database.rs +++ b/src/database.rs @@ -22,6 +22,17 @@ use std::path::Path; type Pool = r2d2::Pool; type PooledConn = r2d2::PooledConnection; +/// In-memory representation of the `video_flags` table. Each set holds the +/// video IDs that have the named flag enabled β€” kept small (a few hundred +/// to a few thousand entries in practice). +#[derive(Default, Clone, Debug)] +pub struct VideoFlagsBundle { + pub bookmark: HashSet, + pub favourite: HashSet, + pub waiting: HashSet, + pub archive: HashSet, +} + /// Default pool size for a file-backed database. Small intentionally β€” the /// app is single-user and our queries are short. A handful is plenty. const FILE_POOL_SIZE: u32 = 4; @@ -110,6 +121,14 @@ impl Database { options_json TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (platform, handle) + ); + CREATE TABLE IF NOT EXISTS video_flags ( + video_id TEXT PRIMARY KEY, + bookmark INTEGER NOT NULL DEFAULT 0, + favourite INTEGER NOT NULL DEFAULT 0, + waiting INTEGER NOT NULL DEFAULT 0, + archive INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );", )?; Ok(()) @@ -149,6 +168,58 @@ impl Database { Ok(()) } + /// Set or clear a single per-video flag. `flag` must be one of + /// `"bookmark"`, `"favourite"`, `"waiting"`, `"archive"` β€” the only + /// columns in `video_flags`. Returns an error if `flag` is unknown so a + /// typo doesn't silently no-op. + /// + /// A row is upserted on first call; once any flag on a video is set, + /// the row sticks around even after all flags are cleared. This keeps + /// the schema simple at the cost of a few orphan rows. + pub fn set_video_flag(&self, video_id: &str, flag: &str, value: bool) -> Result<()> { + let col = match flag { + "bookmark" | "favourite" | "waiting" | "archive" => flag, + _ => return Err(rusqlite::Error::InvalidParameterName(flag.to_string())), + }; + let conn = self.conn(); + // SQLite doesn't allow parameterised column names; we validated `col` + // against an allow-list above so direct interpolation is safe. + let sql = format!( + "INSERT INTO video_flags (video_id, {col}) VALUES (?1, ?2) + ON CONFLICT(video_id) DO UPDATE SET {col} = ?2, updated_at = CURRENT_TIMESTAMP" + ); + conn.execute(&sql, rusqlite::params![video_id, value as i32])?; + Ok(()) + } + + /// Bulk fetch every video's flag set, grouped by flag. Used at startup + + /// after rescan to hydrate the in-memory caches. The four returned sets + /// hold the IDs of videos with each flag set to true. + pub fn get_video_flags(&self) -> Result { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT video_id, bookmark, favourite, waiting, archive FROM video_flags", + )?; + let mut bundle = VideoFlagsBundle::default(); + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i32>(1)? != 0, + row.get::<_, i32>(2)? != 0, + row.get::<_, i32>(3)? != 0, + row.get::<_, i32>(4)? != 0, + )) + })?; + for row in rows.flatten() { + let (id, b, f, w, a) = row; + if b { bundle.bookmark.insert(id.clone()); } + if f { bundle.favourite.insert(id.clone()); } + if w { bundle.waiting.insert(id.clone()); } + if a { bundle.archive.insert(id.clone()); } + } + Ok(bundle) + } + /// Bulk fetch of every channel's options, returned as /// `((platform, handle) β†’ options_json)`. Used by the library scanner to /// attach options to each scanned [`crate::library::Channel`] without diff --git a/src/download_options.rs b/src/download_options.rs index bd5e7d5..b20a79d 100644 --- a/src/download_options.rs +++ b/src/download_options.rs @@ -72,6 +72,13 @@ pub struct DownloadOptions { /// to Tartube's `extra_cmd_string`. #[serde(default)] pub extra_args: Vec, + + /// Embed video comments into the info.json sidecar (yt-dlp + /// `--write-comments`). Off by default because comment download is slow + /// β€” yt-dlp paginates through thousands of replies for popular videos. + /// When on, the web UI's player modal exposes a Comments tab. + #[serde(default)] + pub fetch_comments: bool, } impl DownloadOptions { @@ -119,6 +126,9 @@ impl DownloadOptions { cmd.arg(arg); } } + if self.fetch_comments { + cmd.arg("--write-comments"); + } } /// Deserialize from the JSON blob stored in `channel_options.options_json`. diff --git a/src/web.rs b/src/web.rs index be5f76e..e82e56e 100644 --- a/src/web.rs +++ b/src/web.rs @@ -71,6 +71,11 @@ pub struct WebState { pub downloader: Mutex, /// Set of video IDs the user has marked as watched (persisted in SQLite). pub watched: Mutex>, + /// Bookmark / favourite / waiting / archive flag sets, hydrated from the + /// `video_flags` SQLite table at startup. Each set holds the video IDs + /// with the named flag enabled. Drives the smart-folder sidebar entries + /// and the per-card action icons. + pub flags: Mutex, /// Last known playback position per video ID in seconds (persisted in SQLite). pub positions: Mutex>, /// SQLite handle. Internally backed by an `r2d2` pool, so concurrent @@ -198,6 +203,12 @@ struct VideoInfo { has_video: bool, has_live_chat: bool, watched: bool, + /// Smart-folder flags. Populated from the in-memory + /// [`WebState::flags`] sets at response-build time. + bookmark: bool, + favourite: bool, + waiting: bool, + archive: bool, video_url: Option, thumb_url: Option, subtitles: Vec, @@ -867,6 +878,7 @@ async fn build_library_payload(state: &Arc) -> LibraryResponse { let lib = state.library.lock().unwrap(); let watched = state.watched.lock().unwrap(); let positions = state.positions.lock().unwrap(); + let flags = state.flags.lock().unwrap(); // file_url() now resolves relative to library_root (= parent of // channels_root) so non-YouTube platforms are reachable at // `/files///