feat(SearchComponents, SearchAssembly64, SearchCSDbNG, SearchCommoServe): implement download progress tracking with streamFetch
This commit is contained in:
parent
c7ad02dbe4
commit
76962cc9bd
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
import { humanFileSize, basename, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -10,7 +10,7 @@ import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
const LEET_BASE = 'https://hackerswithstyle.se/leet';
|
const LEET_BASE = 'https://hackerswithstyle.se/leet';
|
||||||
const PAGE_SIZE = 1000;
|
const PAGE_SIZE = 1000;
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/a64';
|
||||||
|
|
||||||
function leetFetch(path: string, query?: Record<string, string>) {
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
const url = new URL(LEET_BASE + path);
|
const url = new URL(LEET_BASE + path);
|
||||||
|
|
@ -175,6 +175,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
||||||
const [isMounting, setIsMounting] = useState(false);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -311,11 +312,13 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
if (!mountEntry) return;
|
if (!mountEntry) return;
|
||||||
const { item, entry } = mountEntry;
|
const { item, entry } = mountEntry;
|
||||||
setIsMounting(true);
|
setIsMounting(true);
|
||||||
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
try {
|
try {
|
||||||
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
const res = await leetFetch(`/search/bin/${item.id}/${item.category}/${entry.id}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
||||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
|
|
@ -333,6 +336,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
toast.error(`Mount failed: ${e?.message ?? e}`);
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsMounting(false);
|
setIsMounting(false);
|
||||||
|
setMountProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -698,9 +702,27 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{isMounting && (
|
{isMounting && (
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
||||||
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
{mountProgress?.phase === 'saving' ? (
|
||||||
<p className="text-sm text-neutral-500">Downloading…</p>
|
<>
|
||||||
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
||||||
|
<p className="text-sm text-neutral-500">Saving to device…</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-neutral-600">
|
||||||
|
{mountProgress?.total
|
||||||
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
||||||
|
: mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}
|
||||||
|
</p>
|
||||||
|
<div className="w-56 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: mountProgress?.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMounting && (() => {
|
{!isMounting && (() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { basename, joinPath, putFileContents } from '../webdav';
|
import { basename, humanFileSize, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -10,7 +10,7 @@ import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
const CSDB_API = 'https://api.idolpx.com/csdb';
|
const CSDB_API = 'https://api.idolpx.com/csdb';
|
||||||
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/csdbng';
|
||||||
|
|
||||||
function resolveLink(link: string): string {
|
function resolveLink(link: string): string {
|
||||||
return CSDB_DOWNLOAD + link;
|
return CSDB_DOWNLOAD + link;
|
||||||
|
|
@ -94,6 +94,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
||||||
const [isMounting, setIsMounting] = useState(false);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -160,13 +161,15 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
if (!mountEntry) return;
|
if (!mountEntry) return;
|
||||||
const { row, link } = mountEntry;
|
const { row, link } = mountEntry;
|
||||||
setIsMounting(true);
|
setIsMounting(true);
|
||||||
|
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
try {
|
try {
|
||||||
const url = resolveLink(link.Link);
|
const url = resolveLink(link.Link);
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
|
||||||
const fname = link.Filename || basename(link.Link) || row.name;
|
const fname = link.Filename || basename(link.Link) || row.name;
|
||||||
const dest = joinPath(DOWNLOAD_DIR, fname);
|
const dest = joinPath(DOWNLOAD_DIR, fname);
|
||||||
|
setMountProgress(p => p && { ...p, phase: 'saving' });
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
|
|
@ -184,6 +187,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
toast.error(`Mount failed: ${e?.message ?? e}`);
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsMounting(false);
|
setIsMounting(false);
|
||||||
|
setMountProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -492,9 +496,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{isMounting && (
|
{isMounting && (
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
<div className="flex flex-col items-center justify-center py-10 gap-4">
|
||||||
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
{mountProgress?.phase === 'saving' ? (
|
||||||
<p className="text-sm text-neutral-500">Downloading…</p>
|
<>
|
||||||
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
||||||
|
<p className="text-sm text-neutral-500">Saving to device…</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-neutral-600">
|
||||||
|
{mountProgress?.total
|
||||||
|
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
|
||||||
|
: mountProgress?.received ? humanFileSize(mountProgress.received) : 'Downloading…'}
|
||||||
|
</p>
|
||||||
|
<div className="w-56 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: mountProgress?.total ? `${Math.min(100, mountProgress.received / mountProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMounting && (() => {
|
{!isMounting && (() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
import { humanFileSize, basename, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
import { EntryIcon } from './MediaEntry';
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
@ -10,7 +10,7 @@ import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
const LEET_BASE = 'https://commoserve.files.commodore.net/leet';
|
const LEET_BASE = 'https://commoserve.files.commodore.net/leet';
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads/commoserve';
|
||||||
|
|
||||||
function leetFetch(path: string, query?: Record<string, string>) {
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
const url = new URL(LEET_BASE + path);
|
const url = new URL(LEET_BASE + path);
|
||||||
|
|
@ -179,6 +179,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
const [actionItem, setActionItem] = useState<ContentItem | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null);
|
||||||
const [downloading, setDownloading] = useState<number | null>(null);
|
const [downloading, setDownloading] = useState<number | null>(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -305,17 +306,20 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
|
|
||||||
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => {
|
||||||
setDownloading(entry.id);
|
setDownloading(entry.id);
|
||||||
|
setDownloadProgress({ received: 0, total: 0, phase: 'fetching' });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(downloadUrl(item, entry));
|
const res = await fetch(downloadUrl(item, entry));
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await streamFetch(res, p => setDownloadProgress({ ...p, phase: 'fetching' }));
|
||||||
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry));
|
||||||
|
setDownloadProgress(p => p && { ...p, phase: 'saving' });
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
toast.success(`Saved to ${dest}`);
|
toast.success(`Saved to ${dest}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Download failed: ${e?.message ?? e}`);
|
toast.error(`Download failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(null);
|
setDownloading(null);
|
||||||
|
setDownloadProgress(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -660,7 +664,27 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
|
||||||
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
|
<EntryIcon entry={{ name: fname, type: 'file', path: entry.path, size: entry.size, lastModified: new Date(entry.date * 1000), contentType: '' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
{downloading === entry.id && downloadProgress ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{downloadProgress.phase === 'saving'
|
||||||
|
? 'Saving to device…'
|
||||||
|
: downloadProgress.total
|
||||||
|
? `${humanFileSize(downloadProgress.received)} / ${humanFileSize(downloadProgress.total)}`
|
||||||
|
: humanFileSize(downloadProgress.received) || 'Downloading…'}
|
||||||
|
</div>
|
||||||
|
{downloadProgress.phase === 'fetching' && (
|
||||||
|
<div className="h-1 bg-neutral-200 rounded-full overflow-hidden mt-1">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-75"
|
||||||
|
style={{ width: downloadProgress.total ? `${Math.min(100, downloadProgress.received / downloadProgress.total * 100)}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadToSd(selectedItem!, entry)}
|
onClick={() => downloadToSd(selectedItem!, entry)}
|
||||||
|
|
|
||||||
|
|
@ -424,3 +424,27 @@ export function humanFileSize(bytes: number): string {
|
||||||
}
|
}
|
||||||
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FetchProgress = { received: number; total: number };
|
||||||
|
|
||||||
|
export async function streamFetch(
|
||||||
|
res: Response,
|
||||||
|
onProgress: (p: FetchProgress) => void,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
if (!res.body) return res.arrayBuffer();
|
||||||
|
const total = +(res.headers.get('content-length') ?? 0);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let received = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
received += value.byteLength;
|
||||||
|
onProgress({ received, total });
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(received);
|
||||||
|
let off = 0;
|
||||||
|
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
|
||||||
|
return out.buffer;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user