Async Download to Library with progress; Download to client

Backend:
- DownloadTask model + background thread runner: streams the file with
  progress (%, bytes, speed) and a cancel flag, then indexes it, names it
  J-<id>.<ext> and applies the e621 metadata
- DownloadTaskViewSet (create/retrieve/cancel) replaces the synchronous
  endpoint; the status footer's worker counts now reflect download jobs
- Client download proxy (/api/online/file/) streams an e621 original to
  the browser with Content-Disposition: attachment, restricted to the
  configured e621 CDN hosts so it cannot be used as an open proxy

Frontend:
- Online detail: progress bar with percentage, transferred size, speed
  and cancel while downloading; success links to the new J-ID
- New 'Download to client' button available to everyone (guests too)
This commit is contained in:
2026-09-17 12:06:15 -05:00
parent bf00cf36a2
commit 6962e483fc
11 changed files with 509 additions and 63 deletions
+108 -11
View File
@@ -7,13 +7,13 @@ import {
Heart,
HeartOff,
} from "lucide-react";
import { useMemo } from "react";
import { useMemo, useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell";
import { api } from "@/lib/api";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import {
@@ -27,7 +27,7 @@ import {
TAG_CATEGORY_ORDER,
} from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
import type { DownloadTask } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
@@ -116,9 +116,11 @@ export function OnlineDetail({ postId }: { postId: number }) {
},
});
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
const downloadMutation = useMutation({
mutationFn: (target: NonNullable<typeof post>) =>
api<MediaItem>("/api/online/downloads/", {
api<DownloadTask>("/api/online/downloads/", {
method: "POST",
json: {
post_id: target.id,
@@ -127,12 +129,36 @@ export function OnlineDetail({ postId }: { postId: number }) {
post: target,
},
}),
onSuccess: (item) => {
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
navigate(`/detail/${item.j_id}`);
onSuccess: (task) => setDownloadTaskId(task.task_id),
});
const taskQuery = useQuery({
queryKey: ["download-task", downloadTaskId],
queryFn: () =>
api<DownloadTask>(`/api/online/downloads/${downloadTaskId}/`),
enabled: Boolean(downloadTaskId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "pending" || status === "downloading" ? 700 : false;
},
});
const downloadTask = taskQuery.data;
const downloadActive =
downloadTask?.status === "pending" || downloadTask?.status === "downloading";
useEffect(() => {
if (downloadTask?.status === "complete") {
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
}
}, [downloadTask?.status, queryClient]);
function cancelDownload() {
if (!downloadTaskId) return;
void api(`/api/online/downloads/${downloadTaskId}/cancel/`, {
method: "POST",
});
}
if (query.isPending) {
return (
@@ -163,6 +189,11 @@ export function OnlineDetail({ postId }: { postId: number }) {
const imageSrc =
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
const canUpload = Boolean(user?.can_upload);
const clientDownloadUrl = post.file.url
? `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
`${post.file.md5}.${post.file.ext}`,
)}`
: null;
return (
<DetailShell
@@ -213,7 +244,10 @@ export function OnlineDetail({ postId }: { postId: number }) {
<Button
variant="secondary"
disabled={
!canUpload || downloadMutation.isPending || !post.file.url
!canUpload ||
downloadMutation.isPending ||
downloadActive ||
!post.file.url
}
onClick={() => downloadMutation.mutate(post)}
title={
@@ -223,12 +257,22 @@ export function OnlineDetail({ postId }: { postId: number }) {
}
>
<Download className="h-4 w-4" />
{downloadMutation.isPending
{downloadMutation.isPending || downloadActive
? "Downloading…"
: "Download to Library"}
</Button>
)}
{clientDownloadUrl ? (
<a
href={clientDownloadUrl}
className={linkButtonClass}
title="Save the original file straight to your computer"
>
<Download className="h-4 w-4" /> Download to client
</a>
) : null}
<a
href={`https://e621.net/posts/${post.id}`}
target="_blank"
@@ -239,6 +283,59 @@ export function OnlineDetail({ postId }: { postId: number }) {
</a>
</div>
{downloadTask &&
(downloadTask.status === "pending" ||
downloadTask.status === "downloading") ? (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<span className="text-ctp-subtext1">
Downloading to the library… {downloadTask.progress}%
</span>
<span className="font-mono text-ctp-overlay0">
{formatBytes(downloadTask.downloaded)}
{downloadTask.total > 0
? ` / ${formatBytes(downloadTask.total)}`
: ""}
{downloadTask.speed
? ` · ${(downloadTask.speed / 1024 / 1024).toFixed(1)} MB/s`
: ""}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width]"
style={{ width: `${downloadTask.progress}%` }}
/>
</div>
<button
type="button"
onClick={cancelDownload}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
cancel
</button>
</div>
) : downloadTask?.status === "complete" ? (
<p className="flex items-center gap-2 rounded-md border border-ctp-green/30 bg-ctp-green/10 px-3 py-2 text-sm text-ctp-green">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Downloaded to the library
{downloadTask.library_j_id ? (
<Link
to={`/detail/${downloadTask.library_j_id}`}
className="font-mono text-ctp-blue hover:underline"
>
{downloadTask.library_j_id}
</Link>
) : null}
</p>
) : downloadTask?.status === "cancelled" ? (
<p className="text-xs text-ctp-overlay0">Download cancelled.</p>
) : downloadTask?.status === "error" ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{downloadTask.error || "Download failed."}
</p>
) : null}
{favoriteMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(favoriteMutation.error)}
@@ -246,7 +343,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
) : null}
{downloadMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(downloadMutation.error)}
{errorMessage(downloadMutation.error)}
</p>
) : null}
</>
+15
View File
@@ -107,6 +107,21 @@ export interface FileNeighbors {
next: FileNeighbor | null;
}
export interface DownloadTask {
task_id: string;
post_id: number | null;
filename: string;
status: "pending" | "downloading" | "complete" | "error" | "cancelled";
progress: number;
downloaded: number;
total: number;
speed: number | null;
error: string;
library_j_id: string | null;
created_at: string;
updated_at: string;
}
export interface E621IqdbCandidate {
post_id: number | null;
score: number | null;