| 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | import { Checkbox } from "../components/Checkbox.tsx"; |
| 3 | import { type InferResponseType, parseResponse } from "hono/client"; |
| 4 | import ArrowDown from "lucide-solid/icons/arrow-down"; |
| 5 | import ArrowDownToLine from "lucide-solid/icons/arrow-down-to-line"; |
| 6 | import ArrowUp from "lucide-solid/icons/arrow-up"; |
| 7 | import ArrowUpToLine from "lucide-solid/icons/arrow-up-to-line"; |
| 8 | import ChevronDown from "lucide-solid/icons/chevron-down"; |
| 9 | import ChevronUp from "lucide-solid/icons/chevron-up"; |
| 10 | import Download from "lucide-solid/icons/download"; |
| 11 | import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; |
| 12 | import HardDrive from "lucide-solid/icons/hard-drive"; |
| 13 | import Scale from "lucide-solid/icons/scale"; |
| 14 | import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"; |
| 15 | import { UNKNOWN_ETA } from "../types/model.ts"; |
| 16 | import type { Torrent, TorrentState, TORRENT_ACTIONS } from "../types/seedbox.ts"; |
| 17 | import { api, queries, reason, unconnected } from "../api.ts"; |
| 18 | import { Ago } from "../components/Ago.tsx"; |
| 19 | import { Copy } from "../components/Copy.tsx"; |
| 20 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 21 | import { ListPage } from "../components/ListPage.tsx"; |
| 22 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 23 | import { Meter } from "../components/Meter.tsx"; |
| 24 | import { OpenApp } from "../components/OpenApp.tsx"; |
| 25 | import { Reveal } from "../components/Reveal.tsx"; |
| 26 | import { type Sort, SortHeader } from "../components/SortHeader.tsx"; |
| 27 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; |
| 28 | import { TabBar } from "../components/TabBar.tsx"; |
| 29 | import { TimeChart } from "../components/TimeChart.tsx"; |
| 30 | import { toast } from "../components/Toast.tsx"; |
| 31 | import { bytes, count, duration, percent, rate } from "../format.ts"; |
| 32 | import "./Seedbox.css"; |
| 33 | |
| 34 | type Listed = InferResponseType<typeof api.seedbox.$get>["torrents"][number]; |
| 35 | type Group = "downloading" | "seeding" | "paused" | "error"; |
| 36 | |
| 37 | const 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 | |
| 60 | const GROUPS: [Group | "", string][] = [ |
| 61 | ["", "all"], ["downloading", "downloading"], ["seeding", "seeding"], ["paused", "paused"], ["error", "errors"], |
| 62 | ]; |
| 63 | |
| 64 | const 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 | |
| 71 | type Action = (typeof TORRENT_ACTIONS)[number]; |
| 72 | |
| 73 | const 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 | |
| 78 | function 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 | |
| 191 | function 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 | |
| 222 | export 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 | } |