Bulk tagging + channel-name search

Bulk tagging
- Desktop bulk-mode toolbar grows three new buttons next to ✓ Watched
  and ○ Unwatched: ★ Favourite, 🔖 Bookmark,  Waiting. Each applies
  the corresponding flag to every video in the current bulk selection.
- New `App::bulk_set_flag` mirrors the existing `bulk_mark_watched`:
  iterates selection, persists each flag via `Database::set_video_flag`,
  mirrors into the in-memory `flags` bundle, busts the cards-cache so
  the smart-folder counts refresh on the next render.
- Web bulk-actions row gets the same three buttons. New `bulkFlag(flag)`
  fires the POSTs in parallel via Promise.all and reloads the library
  once they complete.
- Tagging in bulk only ever sets a flag — no toggle. Removing flags in
  bulk is rarely what users want; the per-card buttons handle un-flag.

Channel-name search
- Both UIs already filter by video title + id; extend to match the
  channel name too. Searching "Linus" now surfaces every video from a
  channel called "Linus Tech Tips" without typing the title.
- Description matching deliberately skipped — would require reading the
  .description sidecar per-video per-keystroke. Mentioned in the code
  comment as a future "load descriptions into the search index on
  rescan" pass if real users want it.
- New JS helper `matchesSearch(v, chName, q)` centralises the four
  call sites in `currentVideos()` (continue / recent / smart-folder /
  default).

55 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-25 22:14:30 -07:00
parent 9d414919cd
commit c86e9b90a5
2 changed files with 78 additions and 7 deletions

View file

@ -451,9 +451,14 @@ impl App {
let mut cards = Vec::new(); let mut cards = Vec::new();
let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| { let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| {
// Search matches title / id / channel name. Description matching
// would require reading the .description sidecar per-video on every
// keystroke — punted to a future "load descriptions into the
// search index on rescan" pass if users ask for it.
if !query.is_empty() if !query.is_empty()
&& !v.title.to_lowercase().contains(&query) && !v.title.to_lowercase().contains(&query)
&& !v.id.to_lowercase().contains(&query) && !v.id.to_lowercase().contains(&query)
&& !ch_name.to_lowercase().contains(&query)
{ {
return; return;
} }
@ -762,6 +767,32 @@ impl App {
); );
} }
/// Bulk-apply a single per-video flag (`"bookmark"` / `"favourite"` /
/// `"waiting"` / `"archive"`) across the current selection. Same shape
/// as [`bulk_mark_watched`], extended for the smart-folder flag set.
fn bulk_set_flag(&mut self, flag: &'static str, value: bool) {
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
let count = ids.len();
for id in &ids {
if self.db.set_video_flag(id, flag, value).is_err() { continue; }
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,
};
if value { set.insert(id.clone()); } else { set.remove(id); }
}
self.bulk_selected.clear();
self.cards_cache_key = None;
self.status = format!(
"{count} {} {}{flag}",
if count == 1 { "video" } else { "videos" },
if value { "" } else { "un" },
);
}
fn run_scheduled_check(&mut self) { fn run_scheduled_check(&mut self) {
// Snapshot each channel's URL + options first so we don't hold an // Snapshot each channel's URL + options first so we don't hold an
// immutable borrow of `self.library` while calling // immutable borrow of `self.library` while calling
@ -2623,14 +2654,26 @@ impl App {
ui.separator(); ui.separator();
let n = self.bulk_selected.len(); let n = self.bulk_selected.len();
ui.label(format!("{n} selected")); ui.label(format!("{n} selected"));
if ui.button("Mark watched").clicked() { if ui.button("Watched").clicked() {
self.bulk_mark_watched(true); self.bulk_mark_watched(true);
self.bulk_mode = false; self.bulk_mode = false;
} }
if ui.button("Mark unwatched").clicked() { if ui.button("Unwatched").clicked() {
self.bulk_mark_watched(false); self.bulk_mark_watched(false);
self.bulk_mode = false; self.bulk_mode = false;
} }
if ui.button("★ Favourite").on_hover_text("Mark every selected video as favourite").clicked() {
self.bulk_set_flag("favourite", true);
self.bulk_mode = false;
}
if ui.button("🔖 Bookmark").on_hover_text("Mark every selected video as bookmarked").clicked() {
self.bulk_set_flag("bookmark", true);
self.bulk_mode = false;
}
if ui.button("⏳ Waiting").on_hover_text("Add every selected video to the waiting list").clicked() {
self.bulk_set_flag("waiting", true);
self.bulk_mode = false;
}
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {

View file

@ -144,8 +144,11 @@
<div class="toolbar"> <div class="toolbar">
<label style="font-size:12px;color:var(--muted)"><input type="checkbox" id="bulk" onchange="toggleBulk()"> Select</label> <label style="font-size:12px;color:var(--muted)"><input type="checkbox" id="bulk" onchange="toggleBulk()"> Select</label>
<span id="bulk-actions" style="display:none;gap:6px"> <span id="bulk-actions" style="display:none;gap:6px">
<button onclick="bulkWatched(true)">Mark watched</button> <button onclick="bulkWatched(true)">Watched</button>
<button onclick="bulkWatched(false)">○ Unwatch</button> <button onclick="bulkWatched(false)">○ Unwatch</button>
<button onclick="bulkFlag('favourite')" title="Mark every selected video as favourite">★ Favourite</button>
<button onclick="bulkFlag('bookmark')" title="Mark every selected video as bookmarked">🔖 Bookmark</button>
<button onclick="bulkFlag('waiting')" title="Add every selected video to the waiting list">⏳ Waiting</button>
<span id="sel-count" style="font-size:12px;color:var(--muted)"></span> <span id="sel-count" style="font-size:12px;color:var(--muted)"></span>
</span> </span>
</div> </div>
@ -323,7 +326,7 @@ function currentVideos(){
if(showContinue){ if(showContinue){
for(const ch of library) for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||matchesSearch(v,ch?.name||v.channel,q)))
vids.push({...v,channel:ch.name}); vids.push({...v,channel:ch.name});
vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0)); vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0));
return vids; return vids;
@ -332,7 +335,7 @@ function currentVideos(){
// Most-recently-modified across the whole library, capped at 100. // Most-recently-modified across the whole library, capped at 100.
for(const ch of library) for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v.mtime_unix&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) if(v.mtime_unix&&(!q||matchesSearch(v,ch?.name||v.channel,q)))
vids.push({...v,channel:ch.name}); vids.push({...v,channel:ch.name});
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);
@ -342,7 +345,7 @@ function currentVideos(){
const want = showFavourites ? 'favourite' : showBookmarks ? 'bookmark' : 'waiting'; const want = showFavourites ? 'favourite' : showBookmarks ? 'bookmark' : 'waiting';
for(const ch of library) for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) 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))) if(v[want]&&(!q||matchesSearch(v,ch?.name||v.channel,q)))
vids.push({...v,channel:ch.name}); vids.push({...v,channel:ch.name});
return vids; return vids;
} }
@ -351,7 +354,7 @@ function currentVideos(){
if(activeChannelIdx!==null&&i!==activeChannelIdx)continue; if(activeChannelIdx!==null&&i!==activeChannelIdx)continue;
const pool=activePlaylistIdx!==null?(ch.playlists[activePlaylistIdx]?.videos||[]):[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]; const pool=activePlaylistIdx!==null?(ch.playlists[activePlaylistIdx]?.videos||[]):[...ch.videos,...ch.playlists.flatMap(p=>p.videos)];
for(const v of pool) for(const v of pool)
if(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))vids.push({...v,channel:ch.name}); if(!q||matchesSearch(v,ch?.name||v.channel,q))vids.push({...v,channel:ch.name});
} }
if(sort==='date-desc')vids.sort((a,b)=>(b.upload_date||'').localeCompare(a.upload_date||'')); if(sort==='date-desc')vids.sort((a,b)=>(b.upload_date||'').localeCompare(a.upload_date||''));
if(sort==='date-asc')vids.sort((a,b)=>{const ax=a.upload_date||'￿',bx=b.upload_date||'￿';return ax.localeCompare(bx)}); if(sort==='date-asc')vids.sort((a,b)=>{const ax=a.upload_date||'￿',bx=b.upload_date||'￿';return ax.localeCompare(bx)});
@ -483,6 +486,22 @@ function playAudio(id,title,url){
function toggleBulk(){bulkMode=document.getElementById('bulk').checked;selected.clear();document.getElementById('bulk-actions').style.display=bulkMode?'flex':'none';renderGrid()} function toggleBulk(){bulkMode=document.getElementById('bulk').checked;selected.clear();document.getElementById('bulk-actions').style.display=bulkMode?'flex':'none';renderGrid()}
function toggleSel(id,on){if(on)selected.add(id);else selected.delete(id);document.getElementById('sel-count').textContent=selected.size+' selected'} function toggleSel(id,on){if(on)selected.add(id);else selected.delete(id);document.getElementById('sel-count').textContent=selected.size+' selected'}
async function bulkWatched(on){await Promise.all([...selected].map(id=>api('/api/watched/'+id,{method:'POST'})));selected.clear();await loadLibrary()} async function bulkWatched(on){await Promise.all([...selected].map(id=>api('/api/watched/'+id,{method:'POST'})));selected.clear();await loadLibrary()}
async function bulkFlag(flag){
// Set the chosen flag on every selected video in parallel, then refresh.
// Flag stays on (toggle-off in bulk is rarely what users want — opt for
// explicit setting only, matching the desktop behaviour).
const ids=[...selected];
if(!ids.length){setStatus('Nothing selected');return}
try{
await Promise.all(ids.map(id=>api(
`/api/videos/${encodeURIComponent(id)}/flags/${encodeURIComponent(flag)}`,
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({value:true})}
)));
setStatus(`${ids.length} marked ${flag}`);
selected.clear();
await loadLibrary();
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Download preview ───────────────────────────────────────────── */ /* ── Download preview ───────────────────────────────────────────── */
async function previewDownload(){ async function previewDownload(){
@ -1430,6 +1449,15 @@ schedulePoll(600);
function fmtDur(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`} function fmtDur(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return h?`${h}:${p(m)}:${p(sec)}`:`${m}:${p(sec)}`}
function fmtSize(b){if(b>=1073741824)return(b/1073741824).toFixed(1)+' GB';if(b>=1048576)return Math.round(b/1048576)+' MB';return Math.round(b/1024)+' KB'} function fmtSize(b){if(b>=1073741824)return(b/1073741824).toFixed(1)+' GB';if(b>=1048576)return Math.round(b/1048576)+' MB';return Math.round(b/1024)+' KB'}
function fmtDate(d){if(!d||d.length<8)return d||'';return d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8)} function fmtDate(d){if(!d||d.length<8)return d||'';return d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8)}
// Title + id + channel-name match for the filter input. Lowercase once
// per call so callers don't have to thread the lowercased value around.
function matchesSearch(v,chName,q){
if(!q)return true;
const t=(v.title||'').toLowerCase();
const id=(v.id||'').toLowerCase();
const ch=(chName||'').toLowerCase();
return t.includes(q)||id.includes(q)||ch.includes(q);
}
function p(n){return String(n).padStart(2,'0')} function p(n){return String(n).padStart(2,'0')}
function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')} function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
/* Sanitize a URL before inserting into an href/src attribute. Allows /* Sanitize a URL before inserting into an href/src attribute. Allows