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
107
src/web.rs
107
src/web.rs
|
|
@ -71,6 +71,11 @@ pub struct WebState {
|
|||
pub downloader: Mutex<Downloader>,
|
||||
/// Set of video IDs the user has marked as watched (persisted in SQLite).
|
||||
pub watched: Mutex<HashSet<String>>,
|
||||
/// 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<crate::database::VideoFlagsBundle>,
|
||||
/// Last known playback position per video ID in seconds (persisted in SQLite).
|
||||
pub positions: Mutex<HashMap<String, f64>>,
|
||||
/// 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<String>,
|
||||
thumb_url: Option<String>,
|
||||
subtitles: Vec<SubtitleInfo>,
|
||||
|
|
@ -867,6 +878,7 @@ async fn build_library_payload(state: &Arc<WebState>) -> 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/<platform>/<creator>/<video>`.
|
||||
|
|
@ -910,6 +922,10 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
|||
has_video: v.video_path.is_some(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
watched: watched.contains(&v.id),
|
||||
bookmark: flags.bookmark.contains(&v.id),
|
||||
favourite: flags.favourite.contains(&v.id),
|
||||
waiting: flags.waiting.contains(&v.id),
|
||||
archive: flags.archive.contains(&v.id),
|
||||
video_url,
|
||||
thumb_url,
|
||||
subtitles,
|
||||
|
|
@ -1419,6 +1435,57 @@ async fn get_chapters(
|
|||
(StatusCode::OK, Json(serde_json::json!({"chapters": chapters}))).into_response()
|
||||
}
|
||||
|
||||
/// `GET /api/comments/:id` — extract the `comments` array from a video's
|
||||
/// info.json sidecar and return it as JSON.
|
||||
///
|
||||
/// yt-dlp populates the field when `--write-comments` is in effect (see
|
||||
/// [`crate::download_options::DownloadOptions::fetch_comments`]); the
|
||||
/// response is `{"comments": []}` for videos that haven't had comments
|
||||
/// captured yet. The endpoint filters down to the fields the UI actually
|
||||
/// renders so a 10k-comment dump on a popular video stays manageable.
|
||||
async fn get_comments(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(video_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let info_path = {
|
||||
let lib = state.library.lock().unwrap();
|
||||
find_video_info_path(&lib, &video_id)
|
||||
};
|
||||
let Some(info_path) = info_path else {
|
||||
return (StatusCode::NOT_FOUND, "no info.json").into_response();
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&info_path) else {
|
||||
return (StatusCode::OK, Json(serde_json::json!({"comments": []}))).into_response();
|
||||
};
|
||||
let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
return (StatusCode::OK, Json(serde_json::json!({"comments": []}))).into_response();
|
||||
};
|
||||
let comments = val.get("comments")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter().filter_map(|c| {
|
||||
let id = c.get("id").and_then(|v| v.as_str())?.to_string();
|
||||
let text = c.get("text").and_then(|v| v.as_str())?.to_string();
|
||||
let author = c.get("author").and_then(|v| v.as_str()).map(String::from);
|
||||
let likes = c.get("like_count").and_then(|v| v.as_i64());
|
||||
let parent = c.get("parent").and_then(|v| v.as_str()).map(String::from);
|
||||
let time = c.get("_time_text").and_then(|v| v.as_str())
|
||||
.or_else(|| c.get("time_text").and_then(|v| v.as_str()))
|
||||
.map(String::from);
|
||||
Some(serde_json::json!({
|
||||
"id": id,
|
||||
"author": author,
|
||||
"text": text,
|
||||
"likes": likes,
|
||||
"parent": parent,
|
||||
"time": time,
|
||||
}))
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(StatusCode::OK, Json(serde_json::json!({"comments": comments}))).into_response()
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(video_id): Path<String>,
|
||||
|
|
@ -1532,6 +1599,42 @@ async fn post_maintenance_repair(
|
|||
}
|
||||
|
||||
/// `POST /api/scheduler/run` — trigger an immediate scheduled channel check.
|
||||
#[derive(Deserialize)]
|
||||
struct FlagToggleBody { value: bool }
|
||||
|
||||
/// `POST /api/videos/:id/flags/:flag` — set or clear a single per-video
|
||||
/// flag (`bookmark` / `favourite` / `waiting` / `archive`). The watched
|
||||
/// flag is handled by the separate `POST /api/watched/:id` endpoint for
|
||||
/// backward compatibility.
|
||||
async fn post_video_flag(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path((video_id, flag)): Path<(String, String)>,
|
||||
Json(body): Json<FlagToggleBody>,
|
||||
) -> impl IntoResponse {
|
||||
let set_ref: &str = match flag.as_str() {
|
||||
"bookmark" | "favourite" | "waiting" | "archive" => flag.as_str(),
|
||||
_ => return (StatusCode::BAD_REQUEST, "unknown flag").into_response(),
|
||||
};
|
||||
if let Err(e) = state.db.set_video_flag(&video_id, set_ref, body.value) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
// Mirror into the in-memory bundle so the next GET /api/library reflects
|
||||
// the change without waiting for a rescan.
|
||||
{
|
||||
let mut bundle = state.flags.lock().unwrap();
|
||||
let target = match set_ref {
|
||||
"bookmark" => &mut bundle.bookmark,
|
||||
"favourite" => &mut bundle.favourite,
|
||||
"waiting" => &mut bundle.waiting,
|
||||
"archive" => &mut bundle.archive,
|
||||
_ => unreachable!("validated above"),
|
||||
};
|
||||
if body.value { target.insert(video_id); } else { target.remove(&video_id); }
|
||||
}
|
||||
bump_library_version(&state);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// `GET /api/channels/:platform/:handle/options` — fetch the stored
|
||||
/// download-option overrides for a single channel. Returns the default
|
||||
/// (empty) options when nothing is stored.
|
||||
|
|
@ -1751,11 +1854,13 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
let bind_addr = config.web.bind.clone();
|
||||
|
||||
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
||||
let flags = db.get_video_flags().unwrap_or_default();
|
||||
let state = Arc::new(WebState {
|
||||
library: Mutex::new(library),
|
||||
downloader: Mutex::new(downloader),
|
||||
watched: Mutex::new(watched),
|
||||
positions: Mutex::new(positions),
|
||||
flags: Mutex::new(flags),
|
||||
db,
|
||||
channels_root: channels_root.clone(),
|
||||
library_root: library_root.clone(),
|
||||
|
|
@ -1812,6 +1917,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/progress", get(get_progress))
|
||||
.route("/api/download", post(post_download))
|
||||
.route("/api/watched/:id", post(post_watched))
|
||||
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
|
||||
.route("/api/resume/:id", post(post_resume))
|
||||
.route("/api/preview", get(get_preview))
|
||||
.route("/api/rescan", post(post_rescan))
|
||||
|
|
@ -1819,6 +1925,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/jobs/:idx", axum::routing::delete(post_remove_job))
|
||||
.route("/api/description/:id", get(get_description))
|
||||
.route("/api/chapters/:id", get(get_chapters))
|
||||
.route("/api/comments/:id", get(get_comments))
|
||||
.route("/api/metadata/:id", get(get_metadata))
|
||||
.route("/api/settings", get(get_settings).post(post_settings))
|
||||
.route("/api/transcode/:id", get(get_transcode))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue