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:
@@ -1056,6 +1056,16 @@ pub async fn download_video(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Download queue closed: {e}"))?;
|
.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
|
let out_template = library
|
||||||
.join(downloader::OUTPUT_TEMPLATE)
|
.join(downloader::OUTPUT_TEMPLATE)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
|
|||||||
@@ -561,6 +561,22 @@ impl Db {
|
|||||||
Ok(())
|
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> {
|
pub fn get_download_path(&self, video_id: &str) -> Result<Option<String>, String> {
|
||||||
self.conn
|
self.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -990,6 +1006,20 @@ mod tests {
|
|||||||
assert_eq!(a.state, Some(DownloadState::Running));
|
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]
|
#[test]
|
||||||
fn clearing_a_download_makes_it_undownloaded_again() {
|
fn clearing_a_download_makes_it_undownloaded_again() {
|
||||||
let db = seeded();
|
let db = seeded();
|
||||||
|
|||||||
+26
@@ -242,6 +242,27 @@ export default function App() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [online, refreshing, playingIndex, doRefresh]);
|
}, [online, refreshing, playingIndex, doRefresh]);
|
||||||
|
|
||||||
|
// Everything listed that is not already here or on its way.
|
||||||
|
const pendingDownloads = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter((i) => {
|
||||||
|
const state = live[i.id]?.state ?? i.state;
|
||||||
|
return state !== "done" && state !== "queued" && state !== "running";
|
||||||
|
}),
|
||||||
|
[items, live],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queues the lot in one go. The backend runs two at a time and the rest wait
|
||||||
|
// their turn, so this is a queue rather than a stampede; a video that fails
|
||||||
|
// reports it on its own row instead of throwing a dialog for each one.
|
||||||
|
const downloadAll = useCallback(() => {
|
||||||
|
if (pendingDownloads.length === 0) return;
|
||||||
|
say(`Queued ${pendingDownloads.length} video${pendingDownloads.length === 1 ? "" : "s"}`);
|
||||||
|
for (const i of pendingDownloads) {
|
||||||
|
downloadVideo(i.id, quality, subLangArg).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [pendingDownloads, quality, subLangArg, say]);
|
||||||
|
|
||||||
// Ids on screen still lacking a length, newest first. Joined into a string
|
// Ids on screen still lacking a length, newest first. Joined into a string
|
||||||
// so the effect below only re-runs when the set actually changes.
|
// so the effect below only re-runs when the set actually changes.
|
||||||
const missingDurations = useMemo(
|
const missingDurations = useMemo(
|
||||||
@@ -375,6 +396,10 @@ export default function App() {
|
|||||||
resultCount={items.length}
|
resultCount={items.length}
|
||||||
view={view} onView={setView}
|
view={view} onView={setView}
|
||||||
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
|
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
|
||||||
|
onDownloadAll={
|
||||||
|
channelId && online && pendingDownloads.length > 0 ? downloadAll : undefined
|
||||||
|
}
|
||||||
|
downloadAllCount={pendingDownloads.length}
|
||||||
sidebarHidden={sidebarHidden}
|
sidebarHidden={sidebarHidden}
|
||||||
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
@@ -422,6 +447,7 @@ export default function App() {
|
|||||||
live: live[item.id],
|
live: live[item.id],
|
||||||
online,
|
online,
|
||||||
onOpen: () => openIndex(idx),
|
onOpen: () => openIndex(idx),
|
||||||
|
onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); },
|
||||||
onDownload: () =>
|
onDownload: () =>
|
||||||
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))),
|
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))),
|
||||||
onCancel: () =>
|
onCancel: () =>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ interface Props {
|
|||||||
onShowSidebar: () => void;
|
onShowSidebar: () => void;
|
||||||
/** Present only when there is something to delete. */
|
/** Present only when there is something to delete. */
|
||||||
onDeleteAll?: () => void;
|
onDeleteAll?: () => void;
|
||||||
|
/** Present only on a channel page with videos still to fetch. */
|
||||||
|
onDownloadAll?: () => void;
|
||||||
|
downloadAllCount?: number;
|
||||||
/** Matches the sidebar's inset so the two headers share a baseline. */
|
/** Matches the sidebar's inset so the two headers share a baseline. */
|
||||||
titleBarInset: boolean;
|
titleBarInset: boolean;
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,8 @@ export default function TopBar({
|
|||||||
search, onSearch, downloadedOnly, onDownloadedOnly,
|
search, onSearch, downloadedOnly, onDownloadedOnly,
|
||||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||||
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
|
||||||
|
titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pct = refreshProgress && refreshProgress.total > 0
|
const pct = refreshProgress && refreshProgress.total > 0
|
||||||
? (refreshProgress.done / refreshProgress.total) * 100
|
? (refreshProgress.done / refreshProgress.total) * 100
|
||||||
@@ -105,6 +109,26 @@ export default function TopBar({
|
|||||||
Local
|
Local
|
||||||
</Toggle>
|
</Toggle>
|
||||||
|
|
||||||
|
{onDownloadAll && (
|
||||||
|
<button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
title={`Download all ${downloadAllCount} video${
|
||||||
|
downloadAllCount === 1 ? "" : "s"
|
||||||
|
} listed here, one after another`}
|
||||||
|
aria-label="Download all listed videos"
|
||||||
|
className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500
|
||||||
|
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
|
||||||
|
dark:hover:border-sky-500 dark:hover:text-sky-400`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 3v10.5" />
|
||||||
|
<path d="M7.5 9.5L12 14l4.5-4.5" />
|
||||||
|
<path d="M4 17.5v1A2.5 2.5 0 006.5 21h11a2.5 2.5 0 002.5-2.5v-1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{downloadedOnly && onDeleteAll && (
|
{downloadedOnly && onDeleteAll && (
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteAll}
|
onClick={onDeleteAll}
|
||||||
|
|||||||
@@ -4,19 +4,22 @@ import type { FeedItem } from "../types";
|
|||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import WatchBar from "./WatchBar";
|
import WatchBar from "./WatchBar";
|
||||||
import { clockDuration, compactViews, relativeTime } from "./format";
|
import { clockDuration, compactViews, relativeTime } from "./format";
|
||||||
|
import { CHANNEL_LINK } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: FeedItem;
|
item: FeedItem;
|
||||||
live?: LiveDownload;
|
live?: LiveDownload;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
/** Jump to this video's channel. */
|
||||||
|
onOpenChannel: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoRow({
|
export default function VideoRow({
|
||||||
item, live, online, onOpen, onDownload, onCancel, onDelete,
|
item, live, online, onOpen, onOpenChannel, onDownload, onCancel, onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const downloaded = (live?.state ?? item.state) === "done";
|
const downloaded = (live?.state ?? item.state) === "done";
|
||||||
const src = thumbSrc(item, online);
|
const src = thumbSrc(item, online);
|
||||||
@@ -70,9 +73,13 @@ export default function VideoRow({
|
|||||||
<button onClick={onOpen} className="w-full cursor-pointer text-left">
|
<button onClick={onOpen} className="w-full cursor-pointer text-left">
|
||||||
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
|
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
<button
|
||||||
|
onClick={onOpenChannel}
|
||||||
|
title={`Show only ${item.channel_title}`}
|
||||||
|
className={CHANNEL_LINK + " mt-1"}
|
||||||
|
>
|
||||||
{item.channel_title}
|
{item.channel_title}
|
||||||
</div>
|
</button>
|
||||||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import type { FeedItem } from "../types";
|
|||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import WatchBar from "./WatchBar";
|
import WatchBar from "./WatchBar";
|
||||||
import { clockDuration, compactViews, relativeTime } from "./format";
|
import { clockDuration, compactViews, relativeTime } from "./format";
|
||||||
|
import { CHANNEL_LINK } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: FeedItem;
|
item: FeedItem;
|
||||||
live?: LiveDownload;
|
live?: LiveDownload;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
/** Jump to this video's channel. */
|
||||||
|
onOpenChannel: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -17,7 +20,7 @@ interface Props {
|
|||||||
|
|
||||||
/** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */
|
/** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */
|
||||||
export default function VideoTile({
|
export default function VideoTile({
|
||||||
item, live, online, onOpen, onDownload, onCancel, onDelete,
|
item, live, online, onOpen, onOpenChannel, onDownload, onCancel, onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const downloaded = (live?.state ?? item.state) === "done";
|
const downloaded = (live?.state ?? item.state) === "done";
|
||||||
const src = thumbSrc(item, online);
|
const src = thumbSrc(item, online);
|
||||||
@@ -74,12 +77,13 @@ export default function VideoTile({
|
|||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<button
|
||||||
className="mt-1 h-4 truncate text-[12px] leading-4 text-slate-500 dark:text-slate-400"
|
onClick={onOpenChannel}
|
||||||
title={item.channel_title}
|
title={`Show only ${item.channel_title}`}
|
||||||
|
className={CHANNEL_LINK + " mt-1 h-4 leading-4"}
|
||||||
>
|
>
|
||||||
{item.channel_title}
|
{item.channel_title}
|
||||||
</div>
|
</button>
|
||||||
<div className="mt-0.5 h-4 truncate text-[11px] leading-4 text-slate-400 dark:text-slate-500">
|
<div className="mt-0.5 h-4 truncate text-[11px] leading-4 text-slate-400 dark:text-slate-500">
|
||||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ export function Toast({ message }: { message: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A channel name that takes you to its channel. Quiet until hovered. */
|
||||||
|
export const CHANNEL_LINK =
|
||||||
|
"block w-full cursor-pointer truncate text-left text-[12px] text-slate-500 " +
|
||||||
|
"transition-colors hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
|
||||||
|
|
||||||
/** States a fact in passing — a quality, a status. Never a control. */
|
/** States a fact in passing — a quality, a status. Never a control. */
|
||||||
export function Badge({
|
export function Badge({
|
||||||
children,
|
children,
|
||||||
|
|||||||
Reference in New Issue
Block a user