Per-video state flags + smart folders + comments capture (Tartube parity 1.3/1.4)

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<String> }`
  so subsequent reads are O(1) in-memory hits.
- `WebState.flags` (Mutex<VideoFlagsBundle>) + `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.<set>` 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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 21:57:52 -07:00
parent 9335e0faa8
commit 8281246420
6 changed files with 437 additions and 15 deletions

View file

@ -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<String>,
mtime_unix: Option<u64>,
watched: bool,
bookmark: bool,
favourite: bool,
waiting: bool,
resume_pos: Option<f64>,
}
@ -108,6 +117,9 @@ pub struct App {
card_density: f32,
sort_mode: SortMode,
watched: HashSet<String>,
/// 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<String, f64>,
prev_job_states: HashMap<usize, JobState>,
currently_playing: Option<String>,
@ -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();
}