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();
}

View file

@ -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

View file

@ -72,6 +72,13 @@ pub struct DownloadOptions {
/// to Tartube's `extra_cmd_string`.
#[serde(default)]
pub extra_args: Vec<String>,
/// 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`.

View file

@ -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))

View file

@ -174,6 +174,8 @@
<script>
'use strict';
let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, showContinue=false, showChannels=false, showMusic=false, showRecent=false;
// Smart-folder view selectors — at most one is true at a time.
let showFavourites=false, showBookmarks=false, showWaiting=false;
let musicTracks=[];
let bulkMode=false, selected=new Set(), selectedId=null;
let currentPlayingId=null, saveTimer=null;
@ -219,11 +221,18 @@ function renderSidebar(){
const contVids=allVids.filter(v=>v.resume_pos&&v.resume_pos>5&&!v.watched);
const total=library.reduce((s,c)=>s+c.total_videos,0);
const hasDated=allVids.some(v=>v.mtime_unix);
const favCount=allVids.filter(v=>v.favourite).length;
const bmkCount=allVids.filter(v=>v.bookmark).length;
const waitCount=allVids.filter(v=>v.waiting).length;
const anySmart=showFavourites||showBookmarks||showWaiting;
let h=`<div class="sidebar-label">Library</div>`;
if(contVids.length)h+=`<div class="ch-item${showContinue?' active':''}" onclick="setContinue()">▶ Continue (${contVids.length})</div>`;
if(hasDated)h+=`<div class="ch-item${showRecent?' active':''}" onclick="setRecent()">🕒 Recent additions</div>`;
if(favCount)h+=`<div class="ch-item${showFavourites?' active':''}" onclick="setFavourites()">★ Favourites (${favCount})</div>`;
if(bmkCount)h+=`<div class="ch-item${showBookmarks?' active':''}" onclick="setBookmarks()">🔖 Bookmarks (${bmkCount})</div>`;
if(waitCount)h+=`<div class="ch-item${showWaiting?' active':''}" onclick="setWaiting()">⏳ Waiting (${waitCount})</div>`;
h+=`<div class="ch-item${showChannels?' active':''}" onclick="setChannels()">⊟ Channels (${library.length})</div>`;
h+=`<div class="ch-item${!showContinue&&!showChannels&&!showRecent&&activeChannelIdx===null&&!showMusic?' active':''}" onclick="setView(null,null)">⊞ All (${total})</div>`;
h+=`<div class="ch-item${!showContinue&&!showChannels&&!showRecent&&!anySmart&&activeChannelIdx===null&&!showMusic?' active':''}" onclick="setView(null,null)">⊞ All (${total})</div>`;
h+=`<div class="ch-item${showMusic?' active':''}" onclick="setMusic()">♫ Music (${musicTracks.length})</div>`;
// Group sidebar entries by platform so a multi-platform library reads as
// distinct sections rather than one flat list.
@ -248,11 +257,21 @@ function renderSidebar(){
}
el.innerHTML=h;
}
function setContinue(){showContinue=true;showChannels=false;showMusic=false;showRecent=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setRecent(){showRecent=true;showContinue=false;showChannels=false;showMusic=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setChannels(){showChannels=true;showContinue=false;showMusic=false;showRecent=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setView(ci,pi){showContinue=false;showChannels=false;showMusic=false;showRecent=false;activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setMusic(){showMusic=true;showContinue=false;showChannels=false;showRecent=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
// Reset every view selector to off — call before flipping the new one on.
// Centralising avoids the combinatorial drift of seven view-mode booleans.
function resetViewSelectors(){
showContinue=false;showRecent=false;showChannels=false;showMusic=false;
showFavourites=false;showBookmarks=false;showWaiting=false;
activeChannelIdx=null;activePlaylistIdx=null;
}
function setContinue(){resetViewSelectors();showContinue=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setRecent(){resetViewSelectors();showRecent=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setFavourites(){resetViewSelectors();showFavourites=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setBookmarks(){resetViewSelectors();showBookmarks=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setWaiting(){resetViewSelectors();showWaiting=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setChannels(){resetViewSelectors();showChannels=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setView(ci,pi){resetViewSelectors();activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();closeSidebar();renderSidebar();renderGrid()}
function setMusic(){resetViewSelectors();showMusic=true;selected.clear();closeSidebar();renderSidebar();renderGrid()}
/* ── Grid ───────────────────────────────────────────────────────── */
function currentVideos(){
@ -276,6 +295,15 @@ function currentVideos(){
vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0));
return vids.slice(0,100);
}
// Smart folders driven by the per-video flag bits served in /api/library.
if(showFavourites||showBookmarks||showWaiting){
const want = showFavourites ? 'favourite' : showBookmarks ? 'bookmark' : 'waiting';
for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v[want]&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q)))
vids.push({...v,channel:ch.name});
return vids;
}
for(let i=0;i<library.length;i++){
const ch=library[i];
if(activeChannelIdx!==null&&i!==activeChannelIdx)continue;
@ -321,7 +349,10 @@ function renderGrid(){
<div class="card-meta">${meta||'&nbsp;'}</div>
<div class="card-foot" onclick="event.stopPropagation()">
${playBtn}
<button onclick="toggleWatched('${v.id}')">${v.watched?'✓':'○'}</button>
<button onclick="toggleWatched('${v.id}')" title="Watched">${v.watched?'✓':'○'}</button>
<button onclick="toggleFlag('${v.id}','favourite',this)" title="Favourite" style="${v.favourite?'color:#facc15':''}">${v.favourite?'★':'☆'}</button>
<button onclick="toggleFlag('${v.id}','bookmark',this)" title="Bookmark" style="${v.bookmark?'color:var(--accent)':''}">🔖</button>
<button onclick="toggleFlag('${v.id}','waiting',this)" title="Waiting list" style="${v.waiting?'color:var(--accent)':''}"></button>
</div>
</div>`;
}).join('');
@ -329,6 +360,29 @@ function renderGrid(){
/* ── Watched / bulk ─────────────────────────────────────────────── */
async function toggleWatched(id){try{await api('/api/watched/'+id,{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
/* ── Per-video flags ────────────────────────────────────────────── */
async function toggleFlag(videoId,flag,btn){
// Optimistically flip the visual state; reload from server afterwards.
const v=findVideo(videoId); if(!v){setStatus('Video not found');return}
const newValue=!v[flag];
v[flag]=newValue;
try{
const r=await api(`/api/videos/${encodeURIComponent(videoId)}/flags/${encodeURIComponent(flag)}`,{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:newValue})
});
if(!r.ok)throw new Error(await r.text());
// Reload to update sidebar counts; the in-memory `library` already
// reflects the toggle thanks to the optimistic update above.
await loadLibrary();
}catch(e){
// Undo on failure.
v[flag]=!newValue;
setStatus('Error: '+e.message);
}
}
function renderChannelGrid(){
const q=(document.getElementById('search')?.value||'').toLowerCase();
const grid=document.getElementById('grid');
@ -515,6 +569,9 @@ async function openChannelOptions(idx){
<div class="settings-row"><label>Audio-only</label>
<input type="checkbox" id="opt-audio" ${opts.audio_only?'checked':''}></div>
<div class="settings-hint" style="margin-bottom:4px">Useful for music channels — saves disk + skips video re-encode.</div>
<div class="settings-row"><label>Fetch comments</label>
<input type="checkbox" id="opt-comments" ${opts.fetch_comments?'checked':''}></div>
<div class="settings-hint" style="margin-bottom:4px">Adds <code>--write-comments</code>. Embeds the comment tree into info.json — slow for popular videos but enables the Comments tab in the player.</div>
<div class="settings-row"><label>Bandwidth cap (KB/s)</label>
<input type="number" id="opt-rate" value="${opts.limit_rate_kb||''}" placeholder="off" min="0" style="width:90px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:4px 6px"></div>
<div class="settings-row"><label>Min size (MB)</label>
@ -550,6 +607,7 @@ function readChannelOptionsForm(){
return {
quality:q?q:null,
audio_only:document.getElementById('opt-audio')?.checked||false,
fetch_comments:document.getElementById('opt-comments')?.checked||false,
limit_rate_kb:intOrNull('opt-rate'),
min_filesize_mb:intOrNull('opt-minsz'),
max_filesize_mb:intOrNull('opt-maxsz'),
@ -604,7 +662,11 @@ function playVideo(id){
].filter(Boolean).join(' · ');
const metaLine=headerMeta?`<span style="font-size:11px;color:var(--muted);margin-left:8px">${esc(headerMeta)}</span>`:'';
bg.innerHTML=`<div class="modal">
<div class="modal-hdr"><h2>${esc(v.title)}${metaLine}</h2><button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button></div>
<div class="modal-hdr">
<h2>${esc(v.title)}${metaLine}</h2>
<button onclick="openComments('${esc(id)}')" title="Comments">💬</button>
<button onclick="closeModal(this.closest('.modal-bg'))">✕ Close</button>
</div>
<div class="modal-body"><video id="player-video" src="${esc(safeUrl(v.video_url))}" controls autoplay crossorigin="anonymous">${tracks}</video>${chapPane}</div>
</div>`;
document.body.appendChild(bg);
@ -653,6 +715,48 @@ async function loadChapters(id){
}
function jumpToChapter(s){const v=document.getElementById('player-video');if(v){v.currentTime=s;v.play()}}
/* ── Comments viewer ───────────────────────────────────────────── */
async function openComments(videoId){
const bg=document.createElement('div');bg.className='modal-bg';
bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:640px;width:100%">
<div class="modal-hdr"><h2>💬 Comments</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div id="comments-body" style="overflow-y:auto;max-height:75vh"><em style="color:var(--muted)">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/comments/'+encodeURIComponent(videoId))).json();
renderComments(r.comments||[]);
}catch(e){
document.getElementById('comments-body').innerHTML=`<div style="color:#f87171">Failed to load comments: ${esc(e.message)}</div>`;
}
}
function renderComments(list){
const body=document.getElementById('comments-body');if(!body)return;
if(!list.length){
body.innerHTML='<div style="color:var(--muted);padding:12px">No comments captured for this video. Enable "Fetch comments" in this channel\'s options + re-download.</div>';
return;
}
// Build a tree from parent ids. Top-level = parent missing / not in set.
const byId=new Map(list.map(c=>[c.id,{...c,children:[]}]));
const roots=[];
for(const c of byId.values()){
if(c.parent && byId.has(c.parent)) byId.get(c.parent).children.push(c);
else roots.push(c);
}
const render=(c,depth)=>{
const indent=depth*16;
const author=c.author?`<strong>${esc(c.author)}</strong>`:'<em>unknown</em>';
const meta=[c.time,c.likes!=null?`${c.likes} likes`:null].filter(Boolean).join(' · ');
const replies=c.children.length?c.children.map(r=>render(r,depth+1)).join(''):'';
return `<div style="padding:6px 10px 6px ${10+indent}px;border-bottom:1px solid var(--border);font-size:13px;line-height:1.4">
<div style="margin-bottom:2px">${author}${meta?` <span style="color:var(--muted);font-size:11px">· ${esc(meta)}</span>`:''}</div>
<div style="white-space:pre-wrap;word-wrap:break-word">${esc(c.text)}</div>
</div>${replies}`;
};
body.innerHTML=`<div style="font-size:11px;color:var(--muted);padding:6px 10px;border-bottom:1px solid var(--border)">${list.length} comment${list.length===1?'':'s'}</div>`+roots.map(c=>render(c,0)).join('');
}
/* ── Select / Details ───────────────────────────────────────────── */
function selectVideo(id){selectedId=selectedId===id?null:id;renderGrid();renderDetails()}
function closeDetails(){selectedId=null;renderGrid();renderDetails()}