import type { ChangeEvent, DragEvent, FormEvent } from 'react'; import { useState } from 'react'; import { FileUp, Loader2, X } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { PRIORITY } from '../shared'; import { useSession, useTorrentMutations } from '../useTransmissionData'; // Add by magnet/URL or by .torrent file, one dialog. // // Transmission's torrent-add takes either `filename` (a magnet or an http URL it fetches itself) or // `metainfo` (base64 of the .torrent bytes). It cannot take both, so the two inputs are exclusive here: // picking a file clears the text box and vice versa, rather than letting you fill in both and guess. type AddTorrentDialogProps = { open: boolean; onOpenChange: (open: boolean) => void }; type PickedFile = { name: string; metainfo: string }; /** FileReader gives back a `data:...;base64,XXX` URL; torrent-add wants only the payload. */ async function readAsBase64(file: File): Promise { const buffer = await file.arrayBuffer(); let binary = ''; const bytes = new Uint8Array(buffer); // Chunked because String.fromCharCode(...bytes) blows the argument limit on a torrent with many pieces. for (let i = 0; i < bytes.length; i += 8192) { binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return btoa(binary); } export const AddTorrentDialog = ({ open, onOpenChange }: AddTorrentDialogProps) => { const { session } = useSession(); const { add } = useTorrentMutations(); const [urls, setUrls] = useState(''); const [files, setFiles] = useState([]); const [downloadDir, setDownloadDir] = useState(''); const [labels, setLabels] = useState(''); const [paused, setPaused] = useState(false); const [priority, setPriority] = useState('0'); const [sequential, setSequential] = useState(false); const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(false); const defaultDir = (session?.['download-dir'] as string) ?? ''; const reset = () => { setUrls(''); setFiles([]); setDownloadDir(''); setLabels(''); setPaused(false); setPriority('0'); setSequential(false); }; const close = () => { reset(); onOpenChange(false); }; const takeFiles = async (list: FileList | null) => { if (!list?.length) return; const picked = await Promise.all([...list].map(async (f) => ({ name: f.name, metainfo: await readAsBase64(f) }))); setFiles((prev) => [...prev, ...picked]); setUrls(''); }; const onDrop = (ev: DragEvent) => { ev.preventDefault(); setDragging(false); void takeFiles(ev.dataTransfer.files); }; const onPick = (ev: ChangeEvent) => { void takeFiles(ev.target.files); // Clear the input so picking the same file twice still fires a change event. ev.target.value = ''; }; const urlList = urls .split('\n') .map((s) => s.trim()) .filter(Boolean); const canSubmit = (files.length > 0 || urlList.length > 0) && !busy; const submit = async (ev?: FormEvent) => { ev?.preventDefault(); const common = { downloadDir: downloadDir.trim() || undefined, labels: labels .split(',') .map((l) => l.trim()) .filter(Boolean), paused, bandwidthPriority: Number(priority), sequentialDownload: sequential, }; setBusy(true); let added = 0; let duplicate = 0; let failed = 0; // Sequential rather than Promise.all: torrent-add is not idempotent and the daemon serialises it // anyway, and a partial failure should report which of a paste of twenty magnets got in. for (const file of files) { const result = await add.mutateAsync({ ...common, metainfo: file.metainfo }).catch(() => null); if (!result) failed += 1; else if (result.status === 'duplicate') duplicate += 1; else added += 1; } for (const url of urlList) { const result = await add.mutateAsync({ ...common, filename: url }).catch(() => null); if (!result) failed += 1; else if (result.status === 'duplicate') duplicate += 1; else added += 1; } setBusy(false); const parts = [added && `${added} added`, duplicate && `${duplicate} already present`, failed && `${failed} failed`] .filter(Boolean) .join(', '); if (added || duplicate) toast.success(parts); else if (failed) toast.error(parts); if (!failed) close(); }; return ( (next ? onOpenChange(true) : close())}> Add torrents Paste magnet links or drop .torrent files.