ArrTop::TUI
The full-screen, top-style live view: one line per queue row (download or
live import progress bar) under a header, redrawn continuously.
Concurrency (needs -Dpreview_mt) — three fibers, so a slow/hung backend
never freezes the UI or the keyboard:
- Poller fiber (producer): loops
@poller.rows→@updates, thensleep @refresh. The blocking HTTP call parks this fiber's thread while the UI keeps running on another; a stuck poll can't block quit. - Reader fiber: turns raw keypresses into bytes on
@keys; sends thenilEOF sentinel when stdin closes. - UI fiber (this one, consumer): keeps a cached
rowssnapshot andselects over@keys,@updates, and a modest animatetimeout. It only ever reads cached rows — it never polls — so key-mashing (an arrow key is 3 raw bytes) can't hammer the *arr API.
q/Q/Ctrl-C (or stdin EOF) quits. On quit the poller fiber is stopped
(@stop closed) so nothing leaks, and terminal restore is guaranteed by
Terminal — the ensure here plus signal traps and an at_exit backstop all
funnel to one idempotent restore. No Channel::ClosedError escapes: the
poller watches @stop with receive? (nil on close), and @keys/@updates
are never closed.
Constants
How often the UI redraws the cached rows between polls, so live import
copy bars (read fresh off disk in build_frame) animate smoothly even while
the poller sleeps. This never polls the backends.
Clear from the cursor to the end of the line (drops a prior longer line's tail).
Clear from the cursor to the end of the screen (drops rows a shrunken frame no longer uses).
Cursor-home; each frame starts here so redraws overwrite in place.
Constructors
Class methods
The bytes currently on disk behind an importing row's copy — the numerator
of the SIZE column's disk/total pair — or nil for every other state (so
only rows displayed as Importing show the pair; the rest show just the
size). For an importing row it is the resolved copy progress's real bytes,
which is 0 at the start of a copy and equal to total for a finished
episode (a nil progress ⇒ 0). Pure/unit-testable. row and total are
unused for now but kept so callers pass the row's full context.
Pure reclassification: given a row, the import progress found on disk for it,
whether that file is the folder's active (newest-mtime) copy, and the
effective per-episode target, decide the effective display State and the
ImportProgress? to render.
Only a Sonarr episode row the *arr reports as Importing is touched (a
season pack lists one importing row per episode, all sharing one folder; the
pack is copied one file at a time, so at most one episode's file is active):
- no matching file on disk ⇒
ImportPending(no bar), unlessepisode_has_filesays Sonarr already imported it ⇒ Importing at 100% (a filename-match fallback); - a matching file that is NOT the active/newest copy ⇒ a done episode ⇒
Importing at 100% (synthesize
bytes == target); - a matching file that IS the active/newest copy ⇒ Importing with its real
bar (
file_bytes / target). Movie rows and non-importing rows keep their real state and import.
Counts how many queue rows share each download_id (nil ids ignored). A
Sonarr season pack lists one row per episode, all sharing one download, so
this count is "episodes in the pack". Pure, so it's unit-testable.
The effective per-episode import target for row. The Sonarr API reports the
whole pack's size on every episode row (no per-episode size), so for an
episode row that shares its download_id with others (a season pack of
count > 1) the target is estimated as import_target // count. Single-file
downloads and movies keep the reported import_target.
When sizes is given and yields an EXACT per-episode size for this row (a configured qBittorrent client has the torrent's file list cached and this episode's file matched), that exact size wins over the pack-average estimate. Otherwise (no client / uncached / no match) the estimate below is used, so behaviour is unchanged when no download client is configured. Pure/unit-testable.
Whether row is a completed season-pack episode that should be dropped from the view, so a pack shows only the actively-copying episode plus the not-yet-started (pending) ones. Pure, so it's unit-tested directly.
Keys off the raw on-disk watch (on_disk/active from watch_progress),
not the reclassified display state. Only a Sonarr episode row the *arr
still reports as Importing can prune; a row prunes when either:
- its file is present but is not the folder's active (newest-mtime) copy — its copy finished and Sonarr has moved on to the next episode — or
episode_has_fileis true (Sonarr already imported it; a filename-match fallback for when the on-disk file can't be located). The actively-copying episode (present and active) is kept even when its on-disk bytes overshoot the per-episode estimate; pending episodes (no file, not yet imported), movies, and every non-importing row are kept.
Whether byte means "quit": the stdin-EOF sentinel (nil), q, Q, or
Ctrl-C (ETX, byte 3 — delivered as input because raw mode disables the
SIGINT a cooked terminal would send). Pure, so it's unit-tested directly.
The single source of truth for a row's effective display, shared by the live
TUI (build_frame) and the plain-text snapshot (CLI.print_snapshot) so the
two never disagree. Given a row and the queue's download_id → count map
(from download_group_counts), it chains the per-episode pipeline —
effective_target (split a season pack's total across its episodes) →
watch_progress (read this episode's file + whether it's the active copy) →
display_state_and_progress (reclassify done/active/pending) — and returns
{State, ImportProgress?, prune}: the effective state + progress to render,
plus whether this is a completed pack episode that should be pruned
from the view (see prune_completed?). Both the TUI (build_frame) and the
snapshot (CLI.print_snapshot) key off this single prune flag so the two
agree on what a season pack shows.
Live import (copy) progress for an Importing row that arrtop can watch on
disk, paired with whether the matched file is the folder's active
(newest-mtime) copy. Returns {nil, false} for every non-importing row and
for an importing row it cannot watch (off-host / file not yet created).
target is the effective per-episode target (see effective_target), used
as the bar's denominator. Episode rows use the season/episode-aware watch so
each row watches THIS episode's file out of a season pack (and learns
whether it is the one being copied); movies use the folder-wide newest file.
Instance methods
Builds the full frame string for rows at terminal size: a double-line box (top border with the app title + queue summary + aggregated copy speed, a column-header row, a divider, one data row per queue entry, and a bottom border). The box width tracks cols and the whole frame is capped to the terminal height, degrading by dropping the least-important chrome (divider, then header labels) before it would overflow or wrap. Every line is cleared to end-of-line and the frame ends with clear-to-end-of-screen to erase what a previous, taller frame left behind.
Lines are joined with \r\n (not \n): raw mode disables output
post-processing, so a bare \n would drop down without returning to
column 0.
Whether byte means "quit": the stdin-EOF sentinel (nil), q, Q, or
Ctrl-C (ETX, byte 3 — delivered as input because raw mode disables the
SIGINT a cooked terminal would send). Pure, so it's unit-tested directly.
Runs the TUI until the user quits (or stdin hits EOF). Seeds an empty
snapshot (drawn immediately — no blocking on the first poll), spawns the
producer/reader fibers, then consumes updates and keypresses. The ensure
stops the poller and restores the terminal on every exit.