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:
parent
9335e0faa8
commit
8281246420
6 changed files with 437 additions and 15 deletions
121
src/app.rs
121
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<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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue