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
|
|
@ -580,13 +580,22 @@ the following are all true.
|
||||||
Editor surfaces in both UIs.*
|
Editor surfaces in both UIs.*
|
||||||
- [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and
|
- [ ] **Folder hierarchy** with N-level nesting, drag-to-reparent, and
|
||||||
per-folder default options.
|
per-folder default options.
|
||||||
- [ ] **Smart folders** for Bookmarks / Favourites / Waiting / New /
|
- [x] **Smart folders** for Bookmarks / Favourites / Waiting on top of
|
||||||
Live / Missing, on top of our existing Recent + Continue
|
Recent + Continue Watching. *v1 shipped 2026-05-25: each smart
|
||||||
Watching.
|
folder appears in the sidebar only when at least one video carries
|
||||||
- [ ] **Per-video state flags** persisted (extending the SQLite
|
the matching flag, so a fresh install isn't polluted with empty
|
||||||
schema): `bookmark`, `favourite`, `archive`, `waiting`,
|
rows. Driven by the in-memory flag bundle so no SQL per render.*
|
||||||
`block`, plus a `missing` derived flag from the maintenance
|
- [x] **Per-video state flags** persisted in a new `video_flags`
|
||||||
scan.
|
SQLite table with columns `bookmark`, `favourite`, `waiting`,
|
||||||
|
`archive`. Loaded as four `HashSet<String>` at startup, mutated
|
||||||
|
via `POST /api/videos/:id/flags/:flag` (web) or the per-card
|
||||||
|
action buttons (desktop). `archive` is wired through but UI is
|
||||||
|
deferred until we actually have auto-deletion to gate.
|
||||||
|
- [x] **Comments capture** via the new `fetch_comments` field in
|
||||||
|
DownloadOptions. Adds `--write-comments` to the yt-dlp command
|
||||||
|
when set. New `GET /api/comments/:id` reads the comments array
|
||||||
|
out of info.json and the web player modal's 💬 button renders a
|
||||||
|
threaded viewer.
|
||||||
- [ ] **Profiles** — saved named groups of channels with a "Download
|
- [ ] **Profiles** — saved named groups of channels with a "Download
|
||||||
this profile" action.
|
this profile" action.
|
||||||
- [ ] **Tidy + Refresh** unified into a single bidirectional sync
|
- [ ] **Tidy + Refresh** unified into a single bidirectional sync
|
||||||
|
|
|
||||||
121
src/app.rs
121
src/app.rs
|
|
@ -49,6 +49,12 @@ enum SidebarView {
|
||||||
ContinueWatching,
|
ContinueWatching,
|
||||||
/// Activity feed — recent additions across all channels, sorted by mtime.
|
/// Activity feed — recent additions across all channels, sorted by mtime.
|
||||||
Recent,
|
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,
|
Music,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +70,9 @@ struct Card {
|
||||||
upload_date: Option<String>,
|
upload_date: Option<String>,
|
||||||
mtime_unix: Option<u64>,
|
mtime_unix: Option<u64>,
|
||||||
watched: bool,
|
watched: bool,
|
||||||
|
bookmark: bool,
|
||||||
|
favourite: bool,
|
||||||
|
waiting: bool,
|
||||||
resume_pos: Option<f64>,
|
resume_pos: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +117,9 @@ pub struct App {
|
||||||
card_density: f32,
|
card_density: f32,
|
||||||
sort_mode: SortMode,
|
sort_mode: SortMode,
|
||||||
watched: HashSet<String>,
|
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>,
|
resume_positions: HashMap<String, f64>,
|
||||||
prev_job_states: HashMap<usize, JobState>,
|
prev_job_states: HashMap<usize, JobState>,
|
||||||
currently_playing: Option<String>,
|
currently_playing: Option<String>,
|
||||||
|
|
@ -156,6 +168,7 @@ pub struct App {
|
||||||
struct ChannelOptionsForm {
|
struct ChannelOptionsForm {
|
||||||
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
|
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
|
||||||
audio_only: bool,
|
audio_only: bool,
|
||||||
|
fetch_comments: bool,
|
||||||
limit_rate_kb: String,
|
limit_rate_kb: String,
|
||||||
min_filesize_mb: String,
|
min_filesize_mb: String,
|
||||||
max_filesize_mb: String,
|
max_filesize_mb: String,
|
||||||
|
|
@ -209,6 +222,7 @@ impl App {
|
||||||
library::apply_channel_options(&mut library, &map);
|
library::apply_channel_options(&mut library, &map);
|
||||||
}
|
}
|
||||||
let watched = db.get_watched().unwrap_or_default();
|
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 resume_positions = db.get_positions().unwrap_or_default();
|
||||||
|
|
||||||
let music_root = channels_root.with_file_name("music");
|
let music_root = channels_root.with_file_name("music");
|
||||||
|
|
@ -275,6 +289,7 @@ impl App {
|
||||||
card_density: 1.0,
|
card_density: 1.0,
|
||||||
sort_mode: SortMode::Title,
|
sort_mode: SortMode::Title,
|
||||||
watched,
|
watched,
|
||||||
|
flags,
|
||||||
resume_positions,
|
resume_positions,
|
||||||
prev_job_states: HashMap::new(),
|
prev_job_states: HashMap::new(),
|
||||||
currently_playing: None,
|
currently_playing: None,
|
||||||
|
|
@ -319,6 +334,7 @@ impl App {
|
||||||
ChannelOptionsForm {
|
ChannelOptionsForm {
|
||||||
quality_idx,
|
quality_idx,
|
||||||
audio_only: opts.audio_only,
|
audio_only: opts.audio_only,
|
||||||
|
fetch_comments: opts.fetch_comments,
|
||||||
limit_rate_kb: num(opts.limit_rate_kb),
|
limit_rate_kb: num(opts.limit_rate_kb),
|
||||||
min_filesize_mb: num(opts.min_filesize_mb),
|
min_filesize_mb: num(opts.min_filesize_mb),
|
||||||
max_filesize_mb: num(opts.max_filesize_mb),
|
max_filesize_mb: num(opts.max_filesize_mb),
|
||||||
|
|
@ -348,6 +364,7 @@ impl App {
|
||||||
crate::download_options::DownloadOptions {
|
crate::download_options::DownloadOptions {
|
||||||
quality,
|
quality,
|
||||||
audio_only: f.audio_only,
|
audio_only: f.audio_only,
|
||||||
|
fetch_comments: f.fetch_comments,
|
||||||
limit_rate_kb: parse_num(&f.limit_rate_kb),
|
limit_rate_kb: parse_num(&f.limit_rate_kb),
|
||||||
min_filesize_mb: parse_num(&f.min_filesize_mb),
|
min_filesize_mb: parse_num(&f.min_filesize_mb),
|
||||||
max_filesize_mb: parse_num(&f.max_filesize_mb),
|
max_filesize_mb: parse_num(&f.max_filesize_mb),
|
||||||
|
|
@ -430,6 +447,9 @@ impl App {
|
||||||
upload_date: v.upload_date.clone(),
|
upload_date: v.upload_date.clone(),
|
||||||
mtime_unix: v.mtime_unix,
|
mtime_unix: v.mtime_unix,
|
||||||
watched: self.watched.contains(&v.id),
|
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,
|
resume_pos,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -466,6 +486,24 @@ impl App {
|
||||||
cards.truncate(100);
|
cards.truncate(100);
|
||||||
return cards;
|
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 => {
|
SidebarView::All => {
|
||||||
for ch in &self.library {
|
for ch in &self.library {
|
||||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
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) {
|
fn start_web_server(&mut self) {
|
||||||
if self.web_server_running {
|
if self.web_server_running {
|
||||||
return;
|
return;
|
||||||
|
|
@ -873,6 +933,43 @@ impl App {
|
||||||
self.selected_video = None;
|
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();
|
let music_count = self.music_library.len();
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
|
|
@ -1453,6 +1550,11 @@ impl App {
|
||||||
ui.checkbox(&mut form.audio_only, "Extract audio (best format)");
|
ui.checkbox(&mut form.audio_only, "Extract audio (best format)");
|
||||||
ui.end_row();
|
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.label("Bandwidth cap (KB/s)");
|
||||||
ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off"));
|
ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off"));
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
@ -2308,6 +2410,9 @@ impl App {
|
||||||
let mut clicked_card = false;
|
let mut clicked_card = false;
|
||||||
let mut play_card = false;
|
let mut play_card = false;
|
||||||
let mut toggle_watched_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| {
|
ui.horizontal(|ui| {
|
||||||
let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
|
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() {
|
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
||||||
toggle_watched_card = true;
|
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();
|
let id = card.id.clone();
|
||||||
self.toggle_watched(&id);
|
self.toggle_watched(&id);
|
||||||
}
|
}
|
||||||
|
if let Some(flag) = toggle_flag_card {
|
||||||
|
let id = card.id.clone();
|
||||||
|
self.toggle_video_flag(&id, flag);
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,17 @@ use std::path::Path;
|
||||||
type Pool = r2d2::Pool<SqliteConnectionManager>;
|
type Pool = r2d2::Pool<SqliteConnectionManager>;
|
||||||
type PooledConn = r2d2::PooledConnection<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
|
/// Default pool size for a file-backed database. Small intentionally — the
|
||||||
/// app is single-user and our queries are short. A handful is plenty.
|
/// app is single-user and our queries are short. A handful is plenty.
|
||||||
const FILE_POOL_SIZE: u32 = 4;
|
const FILE_POOL_SIZE: u32 = 4;
|
||||||
|
|
@ -110,6 +121,14 @@ impl Database {
|
||||||
options_json TEXT NOT NULL,
|
options_json TEXT NOT NULL,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (platform, handle)
|
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(())
|
Ok(())
|
||||||
|
|
@ -149,6 +168,58 @@ impl Database {
|
||||||
Ok(())
|
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
|
/// Bulk fetch of every channel's options, returned as
|
||||||
/// `((platform, handle) → options_json)`. Used by the library scanner to
|
/// `((platform, handle) → options_json)`. Used by the library scanner to
|
||||||
/// attach options to each scanned [`crate::library::Channel`] without
|
/// attach options to each scanned [`crate::library::Channel`] without
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@ pub struct DownloadOptions {
|
||||||
/// to Tartube's `extra_cmd_string`.
|
/// to Tartube's `extra_cmd_string`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extra_args: Vec<String>,
|
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 {
|
impl DownloadOptions {
|
||||||
|
|
@ -119,6 +126,9 @@ impl DownloadOptions {
|
||||||
cmd.arg(arg);
|
cmd.arg(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.fetch_comments {
|
||||||
|
cmd.arg("--write-comments");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deserialize from the JSON blob stored in `channel_options.options_json`.
|
/// Deserialize from the JSON blob stored in `channel_options.options_json`.
|
||||||
|
|
|
||||||
107
src/web.rs
107
src/web.rs
|
|
@ -71,6 +71,11 @@ pub struct WebState {
|
||||||
pub downloader: Mutex<Downloader>,
|
pub downloader: Mutex<Downloader>,
|
||||||
/// Set of video IDs the user has marked as watched (persisted in SQLite).
|
/// Set of video IDs the user has marked as watched (persisted in SQLite).
|
||||||
pub watched: Mutex<HashSet<String>>,
|
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).
|
/// Last known playback position per video ID in seconds (persisted in SQLite).
|
||||||
pub positions: Mutex<HashMap<String, f64>>,
|
pub positions: Mutex<HashMap<String, f64>>,
|
||||||
/// SQLite handle. Internally backed by an `r2d2` pool, so concurrent
|
/// SQLite handle. Internally backed by an `r2d2` pool, so concurrent
|
||||||
|
|
@ -198,6 +203,12 @@ struct VideoInfo {
|
||||||
has_video: bool,
|
has_video: bool,
|
||||||
has_live_chat: bool,
|
has_live_chat: bool,
|
||||||
watched: 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>,
|
video_url: Option<String>,
|
||||||
thumb_url: Option<String>,
|
thumb_url: Option<String>,
|
||||||
subtitles: Vec<SubtitleInfo>,
|
subtitles: Vec<SubtitleInfo>,
|
||||||
|
|
@ -867,6 +878,7 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
|
||||||
let lib = state.library.lock().unwrap();
|
let lib = state.library.lock().unwrap();
|
||||||
let watched = state.watched.lock().unwrap();
|
let watched = state.watched.lock().unwrap();
|
||||||
let positions = state.positions.lock().unwrap();
|
let positions = state.positions.lock().unwrap();
|
||||||
|
let flags = state.flags.lock().unwrap();
|
||||||
// file_url() now resolves relative to library_root (= parent of
|
// file_url() now resolves relative to library_root (= parent of
|
||||||
// channels_root) so non-YouTube platforms are reachable at
|
// channels_root) so non-YouTube platforms are reachable at
|
||||||
// `/files/<platform>/<creator>/<video>`.
|
// `/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_video: v.video_path.is_some(),
|
||||||
has_live_chat: v.has_live_chat,
|
has_live_chat: v.has_live_chat,
|
||||||
watched: watched.contains(&v.id),
|
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,
|
video_url,
|
||||||
thumb_url,
|
thumb_url,
|
||||||
subtitles,
|
subtitles,
|
||||||
|
|
@ -1419,6 +1435,57 @@ async fn get_chapters(
|
||||||
(StatusCode::OK, Json(serde_json::json!({"chapters": chapters}))).into_response()
|
(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(
|
async fn get_metadata(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Path(video_id): Path<String>,
|
Path(video_id): Path<String>,
|
||||||
|
|
@ -1532,6 +1599,42 @@ async fn post_maintenance_repair(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/scheduler/run` — trigger an immediate scheduled channel check.
|
/// `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
|
/// `GET /api/channels/:platform/:handle/options` — fetch the stored
|
||||||
/// download-option overrides for a single channel. Returns the default
|
/// download-option overrides for a single channel. Returns the default
|
||||||
/// (empty) options when nothing is stored.
|
/// (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 bind_addr = config.web.bind.clone();
|
||||||
|
|
||||||
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
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 {
|
let state = Arc::new(WebState {
|
||||||
library: Mutex::new(library),
|
library: Mutex::new(library),
|
||||||
downloader: Mutex::new(downloader),
|
downloader: Mutex::new(downloader),
|
||||||
watched: Mutex::new(watched),
|
watched: Mutex::new(watched),
|
||||||
positions: Mutex::new(positions),
|
positions: Mutex::new(positions),
|
||||||
|
flags: Mutex::new(flags),
|
||||||
db,
|
db,
|
||||||
channels_root: channels_root.clone(),
|
channels_root: channels_root.clone(),
|
||||||
library_root: library_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/progress", get(get_progress))
|
||||||
.route("/api/download", post(post_download))
|
.route("/api/download", post(post_download))
|
||||||
.route("/api/watched/:id", post(post_watched))
|
.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/resume/:id", post(post_resume))
|
||||||
.route("/api/preview", get(get_preview))
|
.route("/api/preview", get(get_preview))
|
||||||
.route("/api/rescan", post(post_rescan))
|
.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/jobs/:idx", axum::routing::delete(post_remove_job))
|
||||||
.route("/api/description/:id", get(get_description))
|
.route("/api/description/:id", get(get_description))
|
||||||
.route("/api/chapters/:id", get(get_chapters))
|
.route("/api/chapters/:id", get(get_chapters))
|
||||||
|
.route("/api/comments/:id", get(get_comments))
|
||||||
.route("/api/metadata/:id", get(get_metadata))
|
.route("/api/metadata/:id", get(get_metadata))
|
||||||
.route("/api/settings", get(get_settings).post(post_settings))
|
.route("/api/settings", get(get_settings).post(post_settings))
|
||||||
.route("/api/transcode/:id", get(get_transcode))
|
.route("/api/transcode/:id", get(get_transcode))
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,8 @@
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, showContinue=false, showChannels=false, showMusic=false, showRecent=false;
|
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 musicTracks=[];
|
||||||
let bulkMode=false, selected=new Set(), selectedId=null;
|
let bulkMode=false, selected=new Set(), selectedId=null;
|
||||||
let currentPlayingId=null, saveTimer=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 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 total=library.reduce((s,c)=>s+c.total_videos,0);
|
||||||
const hasDated=allVids.some(v=>v.mtime_unix);
|
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>`;
|
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(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(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${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>`;
|
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
|
// Group sidebar entries by platform so a multi-platform library reads as
|
||||||
// distinct sections rather than one flat list.
|
// distinct sections rather than one flat list.
|
||||||
|
|
@ -248,11 +257,21 @@ function renderSidebar(){
|
||||||
}
|
}
|
||||||
el.innerHTML=h;
|
el.innerHTML=h;
|
||||||
}
|
}
|
||||||
function setContinue(){showContinue=true;showChannels=false;showMusic=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.
|
||||||
function setRecent(){showRecent=true;showContinue=false;showChannels=false;showMusic=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
|
// Centralising avoids the combinatorial drift of seven view-mode booleans.
|
||||||
function setChannels(){showChannels=true;showContinue=false;showMusic=false;showRecent=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
|
function resetViewSelectors(){
|
||||||
function setView(ci,pi){showContinue=false;showChannels=false;showMusic=false;showRecent=false;activeChannelIdx=ci;activePlaylistIdx=pi;selected.clear();closeSidebar();renderSidebar();renderGrid()}
|
showContinue=false;showRecent=false;showChannels=false;showMusic=false;
|
||||||
function setMusic(){showMusic=true;showContinue=false;showChannels=false;showRecent=false;activeChannelIdx=null;activePlaylistIdx=null;selected.clear();closeSidebar();renderSidebar();renderGrid()}
|
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 ───────────────────────────────────────────────────────── */
|
/* ── Grid ───────────────────────────────────────────────────────── */
|
||||||
function currentVideos(){
|
function currentVideos(){
|
||||||
|
|
@ -276,6 +295,15 @@ function currentVideos(){
|
||||||
vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0));
|
vids.sort((a,b)=>(b.mtime_unix||0)-(a.mtime_unix||0));
|
||||||
return vids.slice(0,100);
|
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++){
|
for(let i=0;i<library.length;i++){
|
||||||
const ch=library[i];
|
const ch=library[i];
|
||||||
if(activeChannelIdx!==null&&i!==activeChannelIdx)continue;
|
if(activeChannelIdx!==null&&i!==activeChannelIdx)continue;
|
||||||
|
|
@ -321,7 +349,10 @@ function renderGrid(){
|
||||||
<div class="card-meta">${meta||' '}</div>
|
<div class="card-meta">${meta||' '}</div>
|
||||||
<div class="card-foot" onclick="event.stopPropagation()">
|
<div class="card-foot" onclick="event.stopPropagation()">
|
||||||
${playBtn}
|
${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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -329,6 +360,29 @@ function renderGrid(){
|
||||||
|
|
||||||
/* ── Watched / bulk ─────────────────────────────────────────────── */
|
/* ── Watched / bulk ─────────────────────────────────────────────── */
|
||||||
async function toggleWatched(id){try{await api('/api/watched/'+id,{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
|
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(){
|
function renderChannelGrid(){
|
||||||
const q=(document.getElementById('search')?.value||'').toLowerCase();
|
const q=(document.getElementById('search')?.value||'').toLowerCase();
|
||||||
const grid=document.getElementById('grid');
|
const grid=document.getElementById('grid');
|
||||||
|
|
@ -515,6 +569,9 @@ async function openChannelOptions(idx){
|
||||||
<div class="settings-row"><label>Audio-only</label>
|
<div class="settings-row"><label>Audio-only</label>
|
||||||
<input type="checkbox" id="opt-audio" ${opts.audio_only?'checked':''}></div>
|
<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-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>
|
<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>
|
<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>
|
<div class="settings-row"><label>Min size (MB)</label>
|
||||||
|
|
@ -550,6 +607,7 @@ function readChannelOptionsForm(){
|
||||||
return {
|
return {
|
||||||
quality:q?q:null,
|
quality:q?q:null,
|
||||||
audio_only:document.getElementById('opt-audio')?.checked||false,
|
audio_only:document.getElementById('opt-audio')?.checked||false,
|
||||||
|
fetch_comments:document.getElementById('opt-comments')?.checked||false,
|
||||||
limit_rate_kb:intOrNull('opt-rate'),
|
limit_rate_kb:intOrNull('opt-rate'),
|
||||||
min_filesize_mb:intOrNull('opt-minsz'),
|
min_filesize_mb:intOrNull('opt-minsz'),
|
||||||
max_filesize_mb:intOrNull('opt-maxsz'),
|
max_filesize_mb:intOrNull('opt-maxsz'),
|
||||||
|
|
@ -604,7 +662,11 @@ function playVideo(id){
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
const metaLine=headerMeta?`<span style="font-size:11px;color:var(--muted);margin-left:8px">${esc(headerMeta)}</span>`:'';
|
const metaLine=headerMeta?`<span style="font-size:11px;color:var(--muted);margin-left:8px">${esc(headerMeta)}</span>`:'';
|
||||||
bg.innerHTML=`<div class="modal">
|
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 class="modal-body"><video id="player-video" src="${esc(safeUrl(v.video_url))}" controls autoplay crossorigin="anonymous">${tracks}</video>${chapPane}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(bg);
|
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()}}
|
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 ───────────────────────────────────────────── */
|
/* ── Select / Details ───────────────────────────────────────────── */
|
||||||
function selectVideo(id){selectedId=selectedId===id?null:id;renderGrid();renderDetails()}
|
function selectVideo(id){selectedId=selectedId===id?null:id;renderGrid();renderDetails()}
|
||||||
function closeDetails(){selectedId=null;renderGrid();renderDetails()}
|
function closeDetails(){selectedId=null;renderGrid();renderDetails()}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue