feat(SearchComponents, SearchAssembly64, SearchCSDbNG, SearchCommoServe): implement download progress tracking with streamFetch

This commit is contained in:
Jaime Idolpx 2026-06-16 16:27:50 -04:00
parent c7ad02dbe4
commit 76962cc9bd
4 changed files with 108 additions and 16 deletions

View File

@ -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 && (() => {

View File

@ -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 && (() => {

View File

@ -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)}

View File

@ -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;
}