feat: jump to a channel by name, and queue the whole channel

A video's channel name under the thumbnail is now a link — click it in
either list or tile view and the feed narrows to that channel. It
clears the search box on the way, so arriving at a channel shows the
channel rather than whatever you had been searching for.

On a channel page, an icon button to the right of Local queues every
video listed there. The backend already runs two downloads at a time
and parks the rest, so the whole channel goes into the queue at once
and comes down in order. The button is only there when there is
something left to fetch, and a video that fails reports it on its own
row rather than raising a dialog per failure.

Cancelling a queued download now actually cancels it. Before, a video
waiting for a slot ignored the cancel and started anyway once its turn
came — barely reachable with one-at-a-time downloading, unmissable
when a whole channel is queued. A download re-reads its own state
after claiming a slot and stands down if it is no longer wanted.
This commit is contained in:
vincent
2026-08-29 16:01:16 +02:00
parent dc1efccf87
commit caea8bad8f
7 changed files with 115 additions and 9 deletions
+10
View File
@@ -1056,6 +1056,16 @@ pub async fn download_video(
.await
.map_err(|e| format!("Download queue closed: {e}"))?;
// A whole channel can be enqueued at once, so the wait for a slot can be
// long. Cancelling during that wait has to actually stop the download
// rather than have it start later anyway.
{
let db = state.db.lock().await;
if db.download_state(&video_id)?.as_deref() != Some(DownloadState::Queued.as_str()) {
return Ok(());
}
}
let out_template = library
.join(downloader::OUTPUT_TEMPLATE)
.to_string_lossy()
+30
View File
@@ -561,6 +561,22 @@ impl Db {
Ok(())
}
/// The recorded state, or None if the video was never queued.
pub fn download_state(&self, video_id: &str) -> Result<Option<String>, String> {
self.conn
.query_row(
"SELECT state FROM downloads WHERE video_id = ?1",
params![video_id],
|r| r.get::<_, String>(0),
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})
.map_err(|e| e.to_string())
}
pub fn get_download_path(&self, video_id: &str) -> Result<Option<String>, String> {
self.conn
.query_row(
@@ -990,6 +1006,20 @@ mod tests {
assert_eq!(a.state, Some(DownloadState::Running));
}
#[test]
fn download_state_is_readable_and_absent_until_queued() {
let db = seeded();
assert_eq!(db.download_state("a").unwrap(), None);
db.set_download_state("a", DownloadState::Queued, None).unwrap();
assert_eq!(db.download_state("a").unwrap().as_deref(), Some("queued"));
// What a waiting download checks before it claims a slot.
db.set_download_state("a", DownloadState::Cancelled, None).unwrap();
assert_ne!(
db.download_state("a").unwrap().as_deref(),
Some(DownloadState::Queued.as_str())
);
}
#[test]
fn clearing_a_download_makes_it_undownloaded_again() {
let db = seeded();