feat(SearchCommoServe): refactor download handling to mount functionality with improved progress tracking

This commit is contained in:
Jaime Idolpx 2026-06-17 01:23:15 -04:00
parent 588895d851
commit afd7156a36
2 changed files with 62 additions and 86 deletions

View File

@ -696,7 +696,6 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
<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> <div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
</div> </div>
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
</button> </button>
); );
})} })}

View File

@ -1,5 +1,5 @@
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, 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, clearDirectory, joinPath, putFileContents, streamFetch, type FetchProgress } from '../webdav'; import { humanFileSize, basename, clearDirectory, 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';
@ -9,7 +9,7 @@ import { EntryIcon } from './MediaEntry';
// ─── API ────────────────────────────────────────────────────────────────────── // ─── API ──────────────────────────────────────────────────────────────────────
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 = 1000;
const DOWNLOAD_DIR = '/sd/downloads/commoserve'; const DOWNLOAD_DIR = '/sd/downloads/commoserve';
function leetFetch(path: string, query?: Record<string, string>) { function leetFetch(path: string, query?: Record<string, string>) {
@ -123,10 +123,6 @@ function entryFilename(e: ContentEntry): string {
return basename(e.path) || e.path; return basename(e.path) || e.path;
} }
function downloadUrl(item: ContentItem, entry: ContentEntry): string {
return `${LEET_BASE}/search/bin/${item.id}/${item.category}/${entry.id}`;
}
function RatingStars({ value, max = 10 }: { value?: number; max?: number }) { function RatingStars({ value, max = 10 }: { value?: number; max?: number }) {
if (!value) return null; if (!value) return null;
const pct = Math.round((value / max) * 5); const pct = Math.round((value / max) * 5);
@ -178,8 +174,8 @@ 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 [isMounting, setIsMounting] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | null>(null); const [mountProgress, setMountProgress] = useState<FetchProgress & { phase: 'fetching' | 'saving' | 'image' } | 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);
@ -302,25 +298,27 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
} }
}; };
// ── Download to SD ─────────────────────────────────────────────────────────── // ── Mount ────────────────────────────────────────────────────────────────────
const downloadToSd = async (item: ContentItem, entry: ContentEntry) => { const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
setDownloading(entry.id); if (!mountEntry) return;
setDownloadProgress({ received: 0, total: 0, phase: 'fetching' }); const { item, entry } = mountEntry;
setIsMounting(true);
setMountProgress({ received: 0, total: 0, phase: 'fetching' });
try { try {
await clearDirectory(DOWNLOAD_DIR); await clearDirectory(DOWNLOAD_DIR);
const res = await fetch(downloadUrl(item, entry)); 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 streamFetch(res, p => setDownloadProgress({ ...p, phase: 'fetching' })); const data = await streamFetch(res, p => setMountProgress({ ...p, phase: 'fetching' }));
const mainFname = entryFilename(entry); const mainFname = entryFilename(entry);
const dest = joinPath(DOWNLOAD_DIR, mainFname); const dest = joinPath(DOWNLOAD_DIR, mainFname);
setDownloadProgress(p => p && { ...p, phase: 'saving' }); setMountProgress(p => p && { ...p, phase: 'saving' });
await putFileContents(dest, data); await putFileContents(dest, data);
const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path)); const imgEntry = (entries ?? []).find(e => e.id !== entry.id && /\.(png|jpg|jpeg|gif|bmp)$/i.test(e.path));
if (imgEntry) { if (imgEntry) {
setDownloadProgress(p => p && { ...p, phase: 'image' }); setMountProgress(p => p && { ...p, phase: 'image' });
try { try {
const imgRes = await fetch(downloadUrl(item, imgEntry)); const imgRes = await leetFetch(`/search/bin/${item.id}/${item.category}/${imgEntry.id}`);
if (imgRes.ok) { if (imgRes.ok) {
const imgData = await imgRes.arrayBuffer(); const imgData = await imgRes.arrayBuffer();
const mainBase = mainFname.replace(/\.[^.]+$/, ''); const mainBase = mainFname.replace(/\.[^.]+$/, '');
@ -329,33 +327,26 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
} }
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
} }
toast.success(`Saved to ${dest}`); const newConfig = JSON.parse(JSON.stringify(config));
if (!newConfig.devices) newConfig.devices = {};
if (!newConfig.devices.iec) newConfig.devices.iec = {};
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
const dev = newConfig.devices.iec[key];
dev.url = dest;
delete dev.media_set;
if (!dev.enabled) dev.enabled = 1;
setConfig(newConfig);
setMountEntry(null);
setSelectedItem(null);
toast.success(`Downloaded and mounted "${mainFname}" on device #${key}`);
} catch (e: any) { } catch (e: any) {
toast.error(`Download failed: ${e?.message ?? e}`); toast.error(`Mount failed: ${e?.message ?? e}`);
} finally { } finally {
setDownloading(null); setIsMounting(false);
setDownloadProgress(null); setMountProgress(null);
} }
}; };
// ── Mount ────────────────────────────────────────────────────────────────────
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry) return;
const { item, entry } = mountEntry;
const newConfig = JSON.parse(JSON.stringify(config));
if (!newConfig.devices) newConfig.devices = {};
if (!newConfig.devices.iec) newConfig.devices.iec = {};
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
const dev = newConfig.devices.iec[key];
dev.url = downloadUrl(item, entry);
delete dev.media_set;
if (!dev.enabled) dev.enabled = 1;
setConfig(newConfig);
setMountEntry(null);
toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`);
};
const mountedUrls = useMemo(() => { const mountedUrls = useMemo(() => {
const s = new Set<string>(); const s = new Set<string>();
for (const d of Object.values(config?.devices?.iec ?? {})) { for (const d of Object.values(config?.devices?.iec ?? {})) {
@ -670,57 +661,19 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
<div className="flex flex-col gap-2 py-1"> <div className="flex flex-col gap-2 py-1">
{entries.map(entry => { {entries.map(entry => {
const fname = entryFilename(entry); const fname = entryFilename(entry);
const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry)); const isMounted = mountedUrls.has(joinPath(DOWNLOAD_DIR, entryFilename(entry)));
return ( return (
<div <button
key={entry.id} key={entry.id}
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`} onClick={() => setMountEntry({ item: selectedItem!, entry })}
className={`w-full text-left px-4 py-3 rounded-lg border flex items-center gap-3 transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-neutral-50 hover:border-neutral-300'}`}
> >
<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>
{downloading === entry.id && downloadProgress ? ( <div className="text-xs text-neutral-400">{humanFileSize(entry.size)}</div>
<>
<div className="text-xs text-neutral-500">
{downloadProgress.phase === 'saving'
? 'Saving to device…'
: downloadProgress.phase === 'image'
? 'Saving cover image…'
: 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)}
disabled={downloading === entry.id}
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40"
title="Download to /sd/downloads"
>
{downloading === entry.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <Download className="w-4 h-4" />}
</button>
<button
onClick={() => setMountEntry({ item: selectedItem!, entry })}
className={`p-1.5 rounded-lg ${isMounted ? 'text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
title="Mount on virtual drive"
>
<HardDrive className="w-4 h-4" />
</button>
</div>
); );
})} })}
</div> </div>
@ -730,7 +683,7 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
</Dialog> </Dialog>
{/* Mount device picker */} {/* Mount device picker */}
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}> <Dialog open={mountEntry !== null} onOpenChange={open => { if (!open && !isMounting) setMountEntry(null); }}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle>Mount on Virtual Drive</DialogTitle> <DialogTitle>Mount on Virtual Drive</DialogTitle>
@ -738,7 +691,31 @@ export default function SearchCommoServe({ config, setConfig, onClose }: SearchC
{mountEntry ? entryFilename(mountEntry.entry) : ''} {mountEntry ? entryFilename(mountEntry.entry) : ''}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0"> {isMounting && mountProgress ? (
<div className="py-6 flex flex-col items-center gap-3">
{mountProgress.phase === 'fetching' ? (
<>
<div className="text-sm text-neutral-600">
{mountProgress.total
? `${humanFileSize(mountProgress.received)} / ${humanFileSize(mountProgress.total)}`
: humanFileSize(mountProgress.received) || 'Downloading…'}
</div>
<div className="w-full h-2 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 className="flex items-center gap-2 text-sm text-neutral-600">
<Loader2 className="w-4 h-4 animate-spin" />
{mountProgress.phase === 'image' ? 'Saving cover image…' : 'Saving to device…'}
</div>
)}
</div>
) : null}
<div className={`overflow-y-auto flex-1 min-h-0 ${isMounting ? 'hidden' : ''}`}>
{(() => { {(() => {
const allDevices = Object.entries(config?.devices?.iec ?? {}); const allDevices = Object.entries(config?.devices?.iec ?? {});
const drives = allDevices const drives = allDevices