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
|
|
@ -22,6 +22,17 @@ use std::path::Path;
|
|||
type Pool = r2d2::Pool<SqliteConnectionManager>;
|
||||
type PooledConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||
|
||||
/// 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<String>,
|
||||
pub favourite: HashSet<String>,
|
||||
pub waiting: HashSet<String>,
|
||||
pub archive: HashSet<String>,
|
||||
}
|
||||
|
||||
/// 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<VideoFlagsBundle> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue