1import { useSearchParams } from "@solidjs/router";
2import { Checkbox } from "../components/Checkbox.tsx";
3import { type InferResponseType, parseResponse } from "hono/client";
4import ArrowDown from "lucide-solid/icons/arrow-down";
5import ArrowDownToLine from "lucide-solid/icons/arrow-down-to-line";
6import ArrowUp from "lucide-solid/icons/arrow-up";
7import ArrowUpToLine from "lucide-solid/icons/arrow-up-to-line";
8import ChevronDown from "lucide-solid/icons/chevron-down";
9import ChevronUp from "lucide-solid/icons/chevron-up";
10import Download from "lucide-solid/icons/download";
11import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
12import HardDrive from "lucide-solid/icons/hard-drive";
13import Scale from "lucide-solid/icons/scale";
14import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js";
15import { UNKNOWN_ETA } from "../types/model.ts";
16import type { Torrent, TorrentState, TORRENT_ACTIONS } from "../types/seedbox.ts";
17import { api, queries, reason, unconnected } from "../api.ts";
18import { Ago } from "../components/Ago.tsx";
19import { Copy } from "../components/Copy.tsx";
20import { showConfirmDialog } from "../components/Dialog.tsx";
21import { ListPage } from "../components/ListPage.tsx";
22import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
23import { Meter } from "../components/Meter.tsx";
24import { OpenApp } from "../components/OpenApp.tsx";
25import { Reveal } from "../components/Reveal.tsx";
26import { type Sort, SortHeader } from "../components/SortHeader.tsx";
27import { type StatusKind, StatusLabel } from "../components/Status.tsx";
28import { TabBar } from "../components/TabBar.tsx";
29import { TimeChart } from "../components/TimeChart.tsx";
30import { toast } from "../components/Toast.tsx";
31import { bytes, count, duration, percent, rate } from "../format.ts";
32import "./Seedbox.css";
33
34type Listed = InferResponseType<typeof api.seedbox.$get>["torrents"][number];
35type Group = "downloading" | "seeding" | "paused" | "error";
36
37const STATES: Record<TorrentState, [StatusKind, string, Group]> = {
38 downloading: ["working", "downloading", "downloading"],
39 forcedDL: ["working", "downloading", "downloading"],
40 metaDL: ["working", "starting", "downloading"],
41 forcedMetaDL: ["working", "starting", "downloading"],
42 allocating: ["working", "starting", "downloading"],
43 checkingDL: ["working", "checking", "downloading"],
44 checkingResumeData: ["working", "checking", "downloading"],
45 moving: ["working", "moving", "downloading"],
46 stalledDL: ["degraded", "stalled", "downloading"],
47 queuedDL: ["stopped", "queued", "downloading"],
48 uploading: ["healthy", "seeding", "seeding"],
49 forcedUP: ["healthy", "seeding", "seeding"],
50 stalledUP: ["healthy", "seeding", "seeding"],
51 queuedUP: ["stopped", "queued", "seeding"],
52 checkingUP: ["working", "checking", "seeding"],
53 stoppedDL: ["stopped", "paused", "paused"],
54 stoppedUP: ["stopped", "paused", "paused"],
55 error: ["down", "error", "error"],
56 missingFiles: ["down", "missing files", "error"],
57 unknown: ["down", "unknown", "error"],
58};
59
60const GROUPS: [Group | "", string][] = [
61 ["", "all"], ["downloading", "downloading"], ["seeding", "seeding"], ["paused", "paused"], ["error", "errors"],
62];
63
64const COLUMNS = {
65 name: ["name", (t) => t.name.toLowerCase()],
66 progress: ["progress", (t) => t.progress],
67 size: ["size", (t) => t.size, true],
68 speed: ["speed", (t) => t.dlspeed + t.upspeed, true],
69} satisfies Record<string, [string, (torrent: Torrent) => string | number, true?]>;
70
71type Action = (typeof TORRENT_ACTIONS)[number];
72
73const PRIORITY: [Action, string, typeof ChevronUp][] = [
74 ["topPrio", "move to top", ArrowUpToLine], ["increasePrio", "move up", ChevronUp],
75 ["decreasePrio", "move down", ChevronDown], ["bottomPrio", "move to bottom", ArrowDownToLine],
76];
77
78function Details(props: { torrent: Listed; onChange: () => void }) {
79 const hash = () => props.torrent.hash;
80 const [files, { refetch }] = createResource(hash, (hash) =>
81 parseResponse(api.seedbox.torrents[":hash"].files.$get({ param: { hash } })));
82 const timer = setInterval(refetch, 5000);
83 onCleanup(() => clearInterval(timer));
84 const [pending, setPending] = createSignal<Action | null>(null);
85 const paused = () => STATES[props.torrent.state][2] === "paused";
86 const act = async (action: Action) => {
87 if (pending()) return;
88 setPending(action);
89 try {
90 await parseResponse(api.seedbox.torrents[":hash"][":action"].$post({ param: { hash: hash(), action } }));
91 props.onChange();
92 } catch (failure) {
93 toast(`Couldn't ${action === "stop" ? "pause" : action === "start" ? "resume" : "reorder"} the torrent. ${reason(failure)}`);
94 } finally {
95 setPending(null);
96 }
97 };
98 const remove = () => {
99 const [files, setFiles] = createSignal(false);
100 showConfirmDialog({
101 title: "Remove torrent",
102 description: () => (
103 <><strong class="torrent-name">{props.torrent.name}</strong> stops seeding and leaves the list.{" "}
104 {files() ? "Its files are deleted from disk." : "Its files stay on disk."}</>
105 ),
106 body: () => (
107 <Checkbox name="files" onChange={setFiles}>delete files too</Checkbox>
108 ),
109 confirmLabel: "remove",
110 destructive: true,
111 onConfirm: (form) => parseResponse(api.seedbox.torrents[":hash"].$delete({
112 param: { hash: hash() }, query: { files: form.has("files") ? "1" : "0" },
113 })).then(props.onChange),
114 });
115 };
116 return (
117 <div class="torrent-detail">
118 <div class="torrent-actions">
119 <button class="button small" aria-busy={pending() === "stop" || pending() === "start"}
120 onClick={() => act(paused() ? "start" : "stop")}>
121 {paused() ? "resume" : "pause"}
122 </button>
123 <Show when={props.torrent.priority > 0}>
124 <div class="queue" role="group" aria-label="Queue position">
125 <span>#{props.torrent.priority} in queue</span>
126 <For each={PRIORITY}>
127 {([action, label, Icon]) => (
128 <button class="button small icon-only" aria-label={label} data-tip={label}
129 aria-busy={pending() === action} onClick={() => act(action)}>
130 <Show when={pending() !== action}><Icon size={14} /></Show>
131 </button>
132 )}
133 </For>
134 </div>
135 </Show>
136 <span class="spacer" />
137 <Show when={props.torrent.folder}>
138 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">open folder<ArrowUpRight size={12} /></a>}
139 </Show>
140 <Show when={props.torrent.progress === 1 && props.torrent.zip}>
141 {(href) => <a class="button small" href={href()} target="_blank" rel="noreferrer">download zip<Download size={12} /></a>}
142 </Show>
143 <button class="button small danger" onClick={remove}>remove</button>
144 </div>
145 <dl class="facts">
146 <div><dt>added</dt><dd><Ago t={props.torrent.added_on} /></dd></div>
147 <Show when={props.torrent.category}><div><dt>category</dt><dd>{props.torrent.category}</dd></div></Show>
148 <div data-tip={`${bytes(props.torrent.uploaded)} uploaded`}><dt>ratio</dt><dd>{props.torrent.ratio.toFixed(2)}</dd></div>
149 <div><dt>seeders</dt><dd>{count(props.torrent.num_seeds)} of {count(props.torrent.num_complete)}</dd></div>
150 <div><dt>leechers</dt><dd>{count(props.torrent.num_leechs)} of {count(props.torrent.num_incomplete)}</dd></div>
151 <Show when={props.torrent.dlspeed && props.torrent.upspeed}><div><dt>up</dt><dd>{rate(props.torrent.upspeed)}</dd></div></Show>
152 <div><dt>magnet</dt><dd><Copy value={props.torrent.magnet_uri} label="magnet link">{hash().slice(0, 8)}</Copy></dd></div>
153 </dl>
154 <Loaded data={files} what="this torrent's files" retry={refetch}
155 skeleton={<For each={Array(2)}>{() => <div class="skeleton file-skeleton" />}</For>}>
156 {(files) => (
157 <Show when={files().length} fallback={<p class="empty">No files yet. They appear once the first peer connects.</p>}>
158 <table class="data files">
159 <tbody>
160 <For each={files()}>
161 {(file) => (
162 <tr>
163 <td class="mono name">{file.name.replace(`${props.torrent.name}/`, "")}</td>
164 <td class="num">{bytes(file.size)}</td>
165 <td class="num get">
166 <Show when={file.progress === 1} fallback={
167 <span class="muted" data-tip={`${bytes(file.size * file.progress)} / ${bytes(file.size)}`}>
168 {percent(file.progress * 100)}
169 </span>
170 }>
171 <Show when={file.link}>
172 {(href) => (
173 <a class="download" href={href()} target="_blank" rel="noreferrer" aria-label={`Download ${file.name}`}
174 data-tip="download"><Download size={14} /></a>
175 )}
176 </Show>
177 </Show>
178 </td>
179 </tr>
180 )}
181 </For>
182 </tbody>
183 </table>
184 </Show>
185 )}
186 </Loaded>
187 </div>
188 );
189}
190
191function QuickAdd(props: { onAdded: () => void }) {
192 const [url, setUrl] = createSignal("");
193 const [error, setError] = createSignal("");
194 const [busy, setBusy] = createSignal(false);
195 return (
196 <form class="quick-add" onSubmit={async (event) => {
197 event.preventDefault();
198 if (busy() || !url().trim()) return;
199 setBusy(true);
200 setError("");
201 try {
202 await parseResponse(api.seedbox.torrents.$post({ json: { url: url() } }));
203 setUrl("");
204 props.onAdded();
205 } catch (failure) {
206 setError(reason(failure));
207 } finally {
208 setBusy(false);
209 }
210 }}>
211 <input class="search" placeholder="magnet or .torrent link…" aria-label="Torrent link" aria-invalid={!!error()}
212 aria-errormessage="quick-add-error" value={url()} onInput={(event) => {
213 setUrl(event.currentTarget.value);
214 setError("");
215 }} />
216 <Reveal when={!!url().trim()} axis="x"><button class="button" aria-busy={busy()}>queue</button></Reveal>
217 <Show when={error()}><span id="quick-add-error" class="error" role="alert">{error()}</span></Show>
218 </form>
219 );
220}
221
222export function Seedbox() {
223 const [data, { refetch }] = queries.seedbox.use();
224 const seedbox = lastGood(data);
225 const [history, { refetch: refetchHistory }] = queries.seedboxHistory.use();
226 const lastHistory = lastGood(history);
227 const apps = lastGood(queries.launcher.use()[0]);
228 const timers = [
229 setInterval(() => unconnected(data.error) || refetch(), 3000),
230 setInterval(() => unconnected(history.error) || refetchHistory(), 5000),
231 ];
232 onCleanup(() => timers.forEach(clearInterval));
233
234 const [params, setParams] = useSearchParams<{ state?: string; open?: string }>();
235 const group = () => GROUPS.find(([value]) => value && value === params.state)?.[0] ?? "";
236 const [query, setQuery] = createSignal("");
237 const [sort, setSort] = createSignal<Sort<keyof typeof COLUMNS>>({ key: "progress", desc: false });
238 /** After a filter change, brings the open torrent back into view wherever it sorted to. */
239 const refilter = (change: () => void) => {
240 change();
241 requestAnimationFrame(() => document.querySelector(".torrents tr.torrent.open")?.scrollIntoView({ block: "nearest" }));
242 };
243
244 const [hover, setHover] = createSignal<number | null>(null);
245 /** The rate at the hovered point of the chart, or `now` when the pointer is elsewhere. */
246 const at = (series: number, now: number) => {
247 const index = hover();
248 return index === null ? now : lastHistory()?.[series]?.v[index] ?? now;
249 };
250
251 return (
252 <ListPage id="seedbox" flush
253 head={
254 <div class="page-head">
255 <h1>seedbox</h1>
256 <Show when={apps()?.find((app) => app.id === "qbittorrent")}>{(app) => <OpenApp app={app()} href={app().url} />}</Show>
257 <div class="stats">
258 <Show when={seedbox()}
259 fallback={data.state !== "errored" && <For each={[88, 88, 76, 92, 52]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>}>
260 {(latest) => {
261 const state = () => latest().state;
262 const vpn = (): [StatusKind, string, string] => {
263 const via = state().current_network_interface || "any interface";
264 if (state().connection_status === "disconnected") return ["down", "vpn offline", "no connection"];
265 if (state().connection_status === "firewalled") {
266 return ["degraded", "vpn firewalled", `peers can't reach port ${state().listen_port} on ${via}`];
267 }
268 return ["healthy", "vpn", `connected via ${via}`];
269 };
270 return (
271 <>
272 <span class="stat down" data-tip={`${bytes(state().alltime_dl)} downloaded all time`}>
273 <ArrowDown size={13} aria-label="download" />{rate(at(0, state().dl_info_speed))}
274 </span>
275 <span class="stat up" data-tip={`${bytes(state().alltime_ul)} uploaded all time`}>
276 <ArrowUp size={13} aria-label="upload" />{rate(at(1, state().up_info_speed))}
277 </span>
278 <span class="stat" data-tip="all-time share ratio">
279 <Scale size={13} aria-label="ratio" /><b>{state().global_ratio}</b>
280 </span>
281 <a class="stat" href={latest().downloads ?? undefined} target="_blank" rel="noreferrer"
282 data-tip="free space, open downloads in copyparty">
283 <HardDrive size={13} aria-label="free space" /><b>{state().free_space_on_disk < 0 ? "–" : bytes(state().free_space_on_disk)}</b>
284 </a>
285 <span class="stat" data-tip={vpn()[2]}><StatusLabel health={vpn()[0]}>{vpn()[1]}</StatusLabel></span>
286 </>
287 );
288 }}
289 </Show>
290 </div>
291 <QuickAdd onAdded={refetch} />
292 </div>
293 }
294 summary={
295 // Both come from qBittorrent, so the list alone reports it missing.
296 <Show when={seedbox() || !data.error}>
297 <Loaded data={history} what="transfer history" retry={refetchHistory} skeleton={<div class="skeleton" style={{ height: "132px" }} />}>
298 {(series) => {
299 onCleanup(() => setHover(null));
300 return <div class="transfer"><TimeChart series={series()} format={rate} height={132} legend={false} onHover={setHover} /></div>;
301 }}
302 </Loaded>
303 </Show>
304 }>
305 <Loaded data={data} what="the seedbox" retry={refetch} skeleton={
306 <>
307 <div class="torrent-filters"><div class="skeleton" style={{ height: "30px", width: "340px" }} /></div>
308 <div class="torrents fill">
309 <SkeletonRows count={10} />
310 </div>
311 </>
312 }>
313 {(latest) => {
314 const torrents = () => latest().torrents;
315 const byHash = createMemo(() => new Map(torrents().map((t) => [t.hash, t])));
316 const tally = (group: Group | "") => torrents().filter((t) => !group || STATES[t.state][2] === group).length;
317 const matches = (t: Listed) =>
318 (!group() || STATES[t.state][2] === group()) && t.name.toLowerCase().includes(query().toLowerCase());
319 const shown = createMemo(() => {
320 const key = COLUMNS[sort().key][1];
321 const sign = sort().desc ? -1 : 1;
322 return torrents()
323 // The open torrent stays, dimmed, when a filter or an action leaves it out.
324 .filter((t) => t.hash === params.open || matches(t))
325 .sort((a, b) => (key(a) < key(b) ? -sign : key(a) > key(b) ? sign : b.added_on - a.added_on))
326 .map((t) => t.hash);
327 });
328 return (
329 <>
330 <div class="torrent-filters">
331 <TabBar label="Filter by state">
332 <For each={GROUPS}>
333 {([value, label]) => (
334 <button aria-pressed={group() === value} classList={{ alarm: value === "error" && tally(value) > 0 }}
335 onClick={() => refilter(() => setParams({ state: value || undefined }))}>
336 {label} <span class="count">{count(tally(value))}</span>
337 </button>
338 )}
339 </For>
340 </TabBar>
341 <input class="search" type="search" placeholder="filter by name…" aria-label="Filter by name"
342 onInput={(event) => refilter(() => setQuery(event.currentTarget.value))} />
343 </div>
344 <div class="torrents fill">
345 <Show when={shown().length} fallback={
346 <div class="empty">
347 {torrents().length ? "No torrents match." : "No torrents yet. Paste a magnet link above to add one."}
348 </div>
349 }>
350 <table class="data">
351 <thead>
352 <tr>
353 <For each={Object.keys(COLUMNS) as (keyof typeof COLUMNS)[]}>
354 {(key) => (
355 <SortHeader column={key} label={COLUMNS[key][0]} num={COLUMNS[key][2]} class={key} sort={sort()}
356 onSort={setSort} />
357 )}
358 </For>
359 </tr>
360 </thead>
361 <tbody>
362 <For each={shown()}>
363 {(hash) => (
364 <Show when={byHash().get(hash)}>
365 {(torrent) => {
366 const info = () => STATES[torrent().state];
367 return (
368 <>
369 <tr class="torrent"
370 classList={{ open: params.open === hash, paused: info()[2] === "paused", outside: !matches(torrent()) }}
371 onClick={() => setParams({ open: params.open === hash ? undefined : hash }, { replace: true })}>
372 <td class="name">
373 <button aria-expanded={params.open === hash}>{torrent().name}</button>
374 </td>
375 <td class="progress" data-tip={torrent().progress < 1
376 ? `${bytes(torrent().size * torrent().progress)} / ${bytes(torrent().size)} (${percent(torrent().progress * 100)})`
377 : info()[2] === "seeding"
378 ? `${bytes(torrent().uploaded)} uploaded, ratio ${torrent().ratio.toFixed(2)}` : undefined}>
379 <div>
380 <Show when={torrent().progress < 1}><Meter value={torrent().progress} /></Show>
381 <StatusLabel health={info()[0]}>
382 {info()[2] === "downloading" && torrent().eta < UNKNOWN_ETA ? `${duration(torrent().eta)} left` : info()[1]}
383 </StatusLabel>
384 </div>
385 </td>
386 <td class="num">{bytes(torrent().size)}</td>
387 <td class="num speed" data-tip={info()[2] === "downloading" || info()[2] === "seeding"
388 ? `${count(torrent().num_seeds)} of ${count(torrent().num_complete)} seeders · `
389 + `${count(torrent().num_leechs)} of ${count(torrent().num_incomplete)} leechers`
390 : undefined}>
391 <Show when={torrent().dlspeed} fallback={
392 <Show when={torrent().upspeed}>
393 <span class="up"><ArrowUp size={12} aria-label="up" />{rate(torrent().upspeed)}</span>
394 </Show>
395 }>
396 <span class="down"><ArrowDown size={12} aria-label="down" />{rate(torrent().dlspeed)}</span>
397 </Show>
398 </td>
399 </tr>
400 <Show when={params.open === hash}>
401 <tr class="expanded">
402 <td colspan={Object.keys(COLUMNS).length}>
403 <Details torrent={torrent()} onChange={refetch} />
404 </td>
405 </tr>
406 </Show>
407 </>
408 );
409 }}
410 </Show>
411 )}
412 </For>
413 </tbody>
414 </table>
415 </Show>
416 </div>
417 </>
418 );
419 }}
420 </Loaded>
421 </ListPage>
422 );
423}