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:
@@ -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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user