Files
Your Name 0b5043a9f3
gates / consistency-and-conventions (push) Successful in 26s
fix: llama-server start breakage — version detection, flag-validation race, model dir resolution, user-bus pre-flight
User report after the llamacpp app install: 'installed llama.cpp unknown',
valid flags rejected (randomly per run), 'Model not found' for the HF
downloader's own layout, and a systemd user-bus failure over SSH. Detective
(real b10822 binary, FACT) found four independent causes:

- version: llama-server --version prints to STDERR; detect_llama_version's
  2>/dev/null swallowed it -> always 'unknown'. Now captures 2>&1 + accepts
  semver/build tokens (incl. build 1.2.3 edge)
- validation: printf|grep -q under pipefail -> SIGPIPE rc=141 race randomly
  rejected flags present in the 59 KB --help. Now pipe-less grep (no race);
  20x determinism regression test
- model resolution: resolve_model accepted files only, but the HF downloader
  creates <models>/<repo>/file.gguf dirs. Now expands a dir with exactly one
  *.gguf (never silently picks; multi-gguf lists + errs)
- port: llama.cpp default 8080 vs tool/adapter 8088; validation reliability
  means --port is now always pinned in the unit
- user bus: headless/SSH sessions lack XDG_RUNTIME_DIR -> ensure_user_bus in
  lib/common.sh pre-flights all three systemctl --user tools with remediation
  text; pos ai server --no-unit direct-run escape hatch (pidfile) for boxes
  with no bus
- find_llamacpp narrowed to llama-server/llama-server-cuda (bare 'server'
  fallback hazard); installer post-install sanity (version+help execute,
  symlink targets resolve)

Architect decisions DQ1-DQ6 recorded. Tester: 4 new regression files
(version-from-stderr, 25x flag-validation determinism, model dir expansion,
bus pre-flight + E2E) + 3 fixture updates; suite 16 files / 269 checks.

Verified: make gen idempotent; make check OK; make lint 0 FAIL, 0 WARN;
make test 269/269 (~49s); bash -n clean; git diff --check clean.
2026-09-06 09:25:52 -04:00

1114 lines
47 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
# POS_DEPS: aria2c jq curl
# POS_EXAMPLES: pos network download add https://example.com/file.zip | Enqueue an HTTP download (auto-starts daemon)
# POS_EXAMPLES: pos network download status | Daemon health + global transfer stats
# POS_EXAMPLES: pos network download watch | Live progress view
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
command -v aria2c &>/dev/null || err "aria2c not found (install aria2)"
command -v jq &>/dev/null || err "jq not found (install jq)"
command -v curl &>/dev/null || err "curl not found (install curl)"
# ── Config / seams (env overrides for tests) ───────────────────
RPC_PORT="${RPC_PORT:-6800}"
RPC_URL="http://127.0.0.1:$RPC_PORT/jsonrpc"
CONFIG_FILE="$CONFIG_DIR/download.env"
DOWNLOAD_DIR="${DOWNLOAD_DIR:-$HOME/Downloads}"
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
SERVICE="pos-aria2.service"
HEALER_SERVICE="pos-aria2-retry.service"
HEALER_TIMER="pos-aria2-retry.timer"
RETRY_STATE="$CONFIG_DIR/download.retry"
NET_PROBE="${NET_PROBE:-timeout 3 bash -c 'exec 3<>/dev/tcp/\$1/\$2' _ 8.8.8.8 53}"
RETRY_INTERVAL="${RETRY_INTERVAL:-30}"
RETRY_VERIFY_SLEEP="${RETRY_VERIFY_SLEEP:-3}"
load_secret() {
# Canonical loader: env-wins, so an exported RPC_SECRET always beats the file.
load_env_file "$CONFIG_FILE"
RPC_SECRET="${RPC_SECRET:-}"
}
load_secret
# Shared awk helpers: hs = human speed, et = human eta (seconds).
ROW_AWK='
function hs(s){ if(s>=1073741824)return sprintf("%.1fG/s", s/1073741824); if(s>=1048576)return sprintf("%.1fM/s", s/1048576); if(s>=1024)return sprintf("%.1fK/s", s/1024); return sprintf("%dB/s", s) }
function et(s){ s=s+0; if(s<=0)return "—"; if(s>=86400)return sprintf("%dd%dh", int(s/86400), int(s%86400/3600)); if(s>=3600)return sprintf("%dh%dm", int(s/3600), int(s%3600/60)); if(s>=60)return sprintf("%dm%ds", int(s/60), s%60); return sprintf("%ds", s) }
'
usage() {
cat <<EOF
Usage: pos network download <command> [args]
aria2 download daemon + queue control. Runs a persistent aria2c with JSON-RPC
(systemd user service on localhost:$RPC_PORT) and drives it via the RPC API.
Bare \`pos network download\` on a terminal (or \`pos network download menu\`)
opens an interactive menu wrapping these commands; arguments stay scriptable.
Commands:
start Start the daemon (installs the systemd user service, generates RPC secret)
stop Stop the daemon and remove the service
status Daemon health + global transfer stats
add <url> [<url>...] Enqueue HTTP/FTP downloads (auto-starts the daemon)
torrent <file|magnet>... Enqueue .torrent files or magnet links
metalink <file|url> Enqueue a .metalink file or URL
list Show active / waiting / finished downloads
info <gid> Full status of one download
files <gid> Files of a download
peers <gid> Peers of a torrent download
pause|resume [gid|all] Pause / resume one or all downloads (default all)
remove [gid|all] Remove one or all (--force kills immediately)
purge Clear finished/error download history
move <gid> <pos> Reorder the waiting queue
limit [gid] <speed> Speed limit, global or per-download (--upload = up; 0 = unlimited)
set <key=value>... Set global aria2 options (--gid <gid> = per-download)
watch [gid] Live progress, 2s refresh (Ctrl+C to detach)
restart <gid> Re-queue a finished/errored download (resumes partial files;
torrents re-add via magnet, HTTP via their original URLs)
retry <gid|all> Smart retry: waits out internet outages, then re-queues and
re-checks; reports real problems (dead source) instead of looping
(--once: single check without waiting — used by the healer timer)
replace <gid> <url> Give a dead download a fresh URL: same dir + file name, resumes
the partial file (status flags downloads that need this)
Options (add / torrent / metalink):
--dir <path> download directory (default: $DOWNLOAD_DIR)
--out <name> output file name (add only)
--split <n> connections per download (default: 16)
--seed torrent: keep seeding after download (torrent only)
--tmux show live progress in a tmux session named after the file
(attach with: tmux attach -t dl-<name>)
Options (retry):
--interval <s> poll seconds while waiting for internet (default: 30)
--max-wait <s> give up waiting after N seconds (default: 0 = wait forever)
--once single pass: no waiting for internet (healer timer mode)
--quiet suppress progress output (exit 0 even if retries failed)
Examples:
pos network download add https://example.com/ubuntu.iso --tmux
pos network download add https://example.com/a.bin https://example.com/b.bin --dir /mnt/hdd/dl
pos network download torrent file.torrent --seed
pos network download list
pos network download limit 2M
pos network download watch
pos network download restart 2e9dffc4
pos network download retry all
pos network download replace 2e9dffc4 https://mirror.example.com/ubuntu.iso
Failed downloads are auto-retried by the 'retry healer' systemd timer while the
daemon runs; it arms on download start and disables itself when nothing is left.
Sources that fail with a real 404/410 are marked permanent — 'status' flags them
with a fresh-link hint and 'replace <gid> <url>' resumes them with a new URL.
Config: RPC secret in $CONFIG_FILE (chmod 600).
Env: RPC_PORT, RPC_SECRET, DOWNLOAD_DIR, NET_PROBE override defaults (test seams).
EOF
exit 0
}
# ── JSON helpers ────────────────────────────────────────────────
json_arr() { jq -nc '$ARGS.positional' --args "$@"; }
json_str() { jq -nc --arg s "$1" '$s'; }
opts_json() { # key=value... → JSON object
local kv k v o="{}"
for kv in "$@"; do
k="${kv%%=*}"; v="${kv#*=}"
o=$(jq -nc --argjson o "$o" --arg k "$k" --arg v "$v" '$o + {($k): $v}')
done
printf '%s' "$o"
}
# ── RPC client ─────────────────────────────────────────────────
rpc() { # rpc <method> [json-args...]
local method="$1"; shift
local params="" a
for a in "$@"; do params="$params,$a"; done
local body
body=$(curl -fsS --noproxy '*' -m 60 -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":[\"token:${RPC_SECRET}\"${params}]}" \
"$RPC_URL" 2>/dev/null) \
|| err "RPC failed — is the daemon running? (pos network download start)"
local ecode
ecode=$(printf '%s' "$body" | jq -r '.error.code // empty' 2>/dev/null || true)
if [ -n "$ecode" ]; then
err "aria2 RPC error $ecode: $(printf '%s' "$body" | jq -r '.error.message // "?"')"
fi
printf '%s' "$body"
}
# ── Daemon lifecycle (systemd user service) ────────────────────
daemon_active() { systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; }
cmd_start() {
if [ -z "$RPC_SECRET" ]; then
load_secret
fi
if [ -z "$RPC_SECRET" ]; then
RPC_SECRET=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write RPC secret to $CONFIG_FILE"
else
mkdir -p "$CONFIG_DIR"
printf 'RPC_SECRET=%s\n' "$RPC_SECRET" > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
log "rpc secret written: $CONFIG_FILE"
fi
fi
mkdir -p "$DOWNLOAD_DIR" 2>/dev/null || warn "cannot create download dir: $DOWNLOAD_DIR"
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) install unit $USER_SYSTEMD_DIR/$SERVICE"
log "(dry-run) systemctl --user daemon-reload && enable --now $SERVICE"
else
# User-bus pre-flight BEFORE writing the unit — abort with remediation
# (no orphaned unit) when the user systemd bus is unreachable.
ensure_user_bus
mkdir -p "$USER_SYSTEMD_DIR"
cat > "$USER_SYSTEMD_DIR/$SERVICE" <<EOF
[Unit]
Description=pos aria2 RPC daemon (linux-post-install)
[Service]
Type=simple
ExecStart=/usr/bin/aria2c --enable-rpc --rpc-listen-port=$RPC_PORT --rpc-secret=$RPC_SECRET --rpc-listen-all=false "--dir=$DOWNLOAD_DIR" --continue=true --max-connection-per-server=16 --split=16 --seed-time=0 --console-log-level=warn
Restart=on-failure
RestartSec=2
KillMode=control-group
TimeoutStopSec=5s
[Install]
WantedBy=default.target
EOF
chmod 644 "$USER_SYSTEMD_DIR/$SERVICE"
systemctl --user daemon-reload
systemctl --user enable --now "$SERVICE"
sleep 1
fi
log "aria2 daemon running on http://127.0.0.1:$RPC_PORT (dir: $DOWNLOAD_DIR)"
if command -v loginctl >/dev/null 2>&1; then
if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then
warn "enable linger so the daemon survives logout: sudo loginctl enable-linger $(id -un)"
fi
fi
}
cmd_stop() {
if [ ! -f "$USER_SYSTEMD_DIR/$SERVICE" ]; then
warn "no aria2 daemon service installed ($SERVICE)"
exit 0
fi
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) systemctl --user disable --now $SERVICE; remove unit"
else
systemctl --user disable --now "$SERVICE" 2>/dev/null || true
rm -f "$USER_SYSTEMD_DIR/$SERVICE"
systemctl --user daemon-reload
fi
log "aria2 daemon stopped and removed"
}
cmd_status() {
if daemon_active; then echo "daemon: running"; else echo "daemon: not running"; fi
if systemctl --user is-enabled "$SERVICE" >/dev/null 2>&1; then
echo "autostart: enabled (starts on login)"
else
echo "autostart: disabled"
fi
echo "rpc: http://127.0.0.1:$RPC_PORT"
[ -f "$CONFIG_FILE" ] && echo "secret: $CONFIG_FILE"
echo "dir: $DOWNLOAD_DIR"
if daemon_active; then
rpc aria2.getGlobalStat | jq -r '"active: \(.result.numActive) waiting: \(.result.numWaiting) stopped: \(.result.numStopped) (history: \(.result.numStoppedTotal))"'
echo "aria2: $(rpc aria2.getVersion | jq -r '.result.version')"
dead_source_advisory
fi
}
dead_source_advisory() {
# Stopped errored downloads whose source is marked permanently failing —
# they need a fresh link via 'replace <gid> <url>'.
local ids
ids=$(grep . "$RETRY_STATE" 2>/dev/null || true)
[ -n "$ids" ] || return 0
rpc aria2.tellStopped 0 200 | jq -r --arg ids "$ids" '
.result[] | select(.status == "error") |
((if .bittorrent then "bt:" + .bittorrent.infoHash else "url:" + (.files[0].uris[0].uri // .files[0].path // "?") end)) as $id |
select(($ids | split("\n")) | index($id)) |
[.gid, (if .bittorrent then (.bittorrent.info.name // "?") else (.files[0].path // "?" | split("/") | last) end)] | @tsv' \
| while IFS=$'\t' read -r gid name; do
printf ' needs fresh link: %s (%s) — pos network download replace %s <new-url>\n' "$name" "$gid" "$gid"
done
}
ensure_daemon() {
if ! daemon_active; then
log "daemon not running — starting it"
cmd_start
fi
}
# ── Submit ─────────────────────────────────────────────────────
basename_url() {
local u="$1" b
b="${u##*/}"; b="${b%%\?*}"; b="${b%%\#*}"
[ -n "$b" ] || b="download"
printf '%s' "$b"
}
cmd_add() {
local dir="$DOWNLOAD_DIR" out="" split="" tmux=0
local urls=()
while [ $# -gt 0 ]; do
case "$1" in
--dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;;
--dir=*) dir="${1#*=}"; shift ;;
--out) out="${2:-}"; shift 2 ;;
--out=*) out="${1#*=}"; shift ;;
--split) split="${2:-}"; shift 2 ;;
--split=*) split="${1#*=}"; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "add: unknown option: $1" ;;
*) urls+=("$1"); shift ;;
esac
done
[ ${#urls[@]} -gt 0 ] || err "add: no URL given"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
dir="${dir/#\~/$HOME}"
ensure_daemon
local oa=()
[ -n "$dir" ] && oa+=("dir=$dir")
[ -n "$out" ] && oa+=("out=$out")
[ -n "$split" ] && oa+=("split=$split")
local resp gid
resp=$(rpc aria2.addUri "$(json_arr "${urls[@]}")" "$(opts_json "${oa[@]}")")
gid=$(printf '%s' "$resp" | jq -r '.result')
log "added $gid: ${urls[*]}"
ensure_healer
if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "${out:-$(basename_url "${urls[0]}")}"; fi
}
cmd_torrent() {
local dir="$DOWNLOAD_DIR" seed=0 tmux=0
local items=()
while [ $# -gt 0 ]; do
case "$1" in
--dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;;
--dir=*) dir="${1#*=}"; shift ;;
--seed) seed=1; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "torrent: unknown option: $1" ;;
*) items+=("$1"); shift ;;
esac
done
[ ${#items[@]} -gt 0 ] || err "torrent: need a .torrent file or magnet link"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
dir="${dir/#\~/$HOME}"
ensure_daemon
local item
for item in "${items[@]}"; do
local resp gid name
if [[ "$item" =~ ^magnet: ]]; then
resp=$(rpc aria2.addUri "$(json_arr "$item")" "$(opts_json "dir=$dir")")
name="magnet"
else
[ -f "$item" ] || err "torrent: no such file: $item"
name=$(basename "$item"); name="${name%.torrent}"
resp=$(rpc aria2.addTorrent "$(json_str "$(base64 -w0 "$item")")" "[]" "$(opts_json "dir=$dir")")
fi
gid=$(printf '%s' "$resp" | jq -r '.result')
if [ "$seed" -eq 1 ]; then
rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "seed-ratio=0")" >/dev/null
log "added torrent $gid: $item (seeding until stopped)"
else
log "added torrent $gid: $item"
fi
if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "$name"; fi
done
ensure_healer
}
cmd_metalink() {
local dir="$DOWNLOAD_DIR" tmux=0 item=""
while [ $# -gt 0 ]; do
case "$1" in
--dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;;
--dir=*) dir="${1#*=}"; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "metalink: unknown option: $1" ;;
*) item="$1"; shift ;;
esac
done
[ -n "$item" ] || err "metalink: need a .metalink file or URL"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
dir="${dir/#\~/$HOME}"
ensure_daemon
local src="$item" name
if [[ "$item" =~ ^https?:// ]]; then
src=$(mktemp)
trap 'rm -f "$src"' EXIT
curl -fsSL --noproxy '*' -m 120 -o "$src" "$item" || err "metalink: failed to fetch $item"
name=$(basename_url "$item")
else
[ -f "$item" ] || err "metalink: no such file: $item"
name=$(basename "$item")
fi
local resp gid
resp=$(rpc aria2.addMetalink "$(json_str "$(base64 -w0 "$src")")" "{}")
gid=$(printf '%s' "$resp" | jq -r '.result')
if [[ "$item" =~ ^https?:// ]]; then
trap - EXIT
rm -f "$src"
fi
log "added metalink $gid: $item"
ensure_healer
if [ "$tmux" -eq 1 ]; then tmux_watch "$gid" "$name"; fi
}
# ── Restart / retry / healer ───────────────────────────────────
net_up() { bash -c "$NET_PROBE" >/dev/null 2>&1; }
resolve_gid() {
# Accept a full 16-hex gid or the unique 8-char prefix shown by 'list'.
local g="${1:-}" hits n
[ -n "$g" ] || err "gid required (see 'pos network download list')"
[[ "$g" =~ ^[0-9a-f]{16}$ ]] && { printf '%s' "$g"; return 0; }
[[ "$g" =~ ^[0-9a-f]{1,15}$ ]] || err "invalid gid: $g (16 hex chars, or the short id from 'list')"
hits=$( { rpc aria2.tellStopped 0 200; rpc aria2.tellActive; rpc aria2.tellWaiting 0 200; } \
| jq -r --arg p "$g" '.result[]? | select(.gid | startswith($p)) | .gid' )
hits=$(printf '%s\n' "$hits" | sed '/^$/d')
n=$(printf '%s\n' "$hits" | sed '/^$/d' | wc -l | tr -d ' ')
[ "$n" -eq 0 ] && err "no download matches gid '$g' (stopped history is lost when the daemon restarts)"
[ "$n" -gt 1 ] && err "gid prefix '$g' is ambiguous — matches: $(printf '%s' "$hits" | paste -sd' ' -)"
printf '%s\n' "$hits"
}
download_name() { # download_name <tellStatus-json> → name of the file / torrent
printf '%s' "$1" | jq -r '(.result.bittorrent.info.name // (.result.files[0].path // "")) | split("/") | last'
}
do_restart() { # do_restart <gid> <dir> <seed 0|1> <split> → prints new gids
local gid="$1" dir="$2" seed="$3" split="$4"
local data st is_bt newgids=() uri fdir fname oa ng d magnet
data=$(rpc aria2.tellStatus "$(json_str "$gid")")
st=$(printf '%s' "$data" | jq -r '.result.status')
case "$st" in
complete|error|removed) ;;
active) err "gid $gid is active — it's already downloading" ;;
*) err "gid $gid has status '$st' — cannot restart" ;;
esac
is_bt=$(printf '%s' "$data" | jq -r 'if .result.bittorrent then 1 else 0 end')
if [ "$is_bt" = "1" ]; then
magnet=$(printf '%s' "$data" | jq -r '.result.bittorrent as $b | "magnet:?xt=urn:btih:\($b.infoHash)" + (($b.announceList // []) | flatten | map("&tr=" + @uri) | join(""))')
d="${dir:-$(printf '%s' "$data" | jq -r '.result.dir')}"; d="${d/#\~/$HOME}"
oa=()
[ -n "$d" ] && oa+=("dir=$d")
[ -n "$split" ] && oa+=("split=$split")
ng=$(rpc aria2.addUri "$(json_arr "$magnet")" "$(opts_json "${oa[@]}")" | jq -r '.result')
newgids+=("$ng")
if [ "$seed" -eq 1 ]; then
rpc aria2.changeOption "$(json_str "$ng")" "$(opts_json "seed-ratio=0")" >/dev/null
fi
else
while IFS=$'\t' read -r uri fdir fname; do
[ -n "$uri" ] || continue
oa=()
[ -n "$fdir" ] && oa+=("dir=$fdir")
[ -n "$fname" ] && oa+=("out=$fname")
[ -n "$split" ] && oa+=("split=$split")
ng=$(rpc aria2.addUri "$(json_arr "$uri")" "$(opts_json "${oa[@]}")" | jq -r '.result')
newgids+=("$ng")
done < <(printf '%s' "$data" | jq -r '.result.files[] | [(.uris[0].uri // ""), (.path | split("/") | .[:-1] | join("/")), (.path | split("/") | last)] | @tsv')
fi
[ ${#newgids[@]} -gt 0 ] || err "gid $gid has no reusable URIs"
printf '%s\n' "${newgids[@]}"
}
cmd_restart() {
local gid="" dir="" split="" seed=0 tmux=0 newgids=() name=""
while [ $# -gt 0 ]; do
case "$1" in
--dir) dir="${2:-}"; shift 2 ;;
--dir=*) dir="${1#*=}"; shift ;;
--seed) seed=1; shift ;;
--split) split="${2:-}"; shift 2 ;;
--split=*) split="${1#*=}"; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "restart: unknown option: $1" ;;
*) gid="$1"; shift ;;
esac
done
[ -n "$gid" ] || err "restart: gid required (see 'pos network download list')"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
dir="${dir/#\~/$HOME}"
ensure_daemon
gid=$(resolve_gid "$gid")
mapfile -t newgids < <(do_restart "$gid" "$dir" "$seed" "$split")
[ ${#newgids[@]} -gt 0 ] || err "restart failed"
name=$(download_name "$(rpc aria2.tellStatus "$(json_str "$gid")")")
[ -n "$name" ] || name="download"
if [ "$seed" -eq 1 ]; then
log "restarted $gid${newgids[*]}: $name (seeding until stopped)"
else
log "restarted $gid${newgids[*]}: $name"
fi
ensure_healer
if [ "$tmux" -eq 1 ]; then tmux_watch "${newgids[0]}" "$name"; fi
}
cmd_replace() { # replace <gid> <url> — fresh link for a dead download, same dir/file (resumes partial)
local gid="" url="" dir="" split="" tmux=0 data st nfiles old_uri odir name newgid oa d
while [ $# -gt 0 ]; do
case "$1" in
--dir) dir="${2:-}"; shift 2 ;;
--dir=*) dir="${1#*=}"; shift ;;
--split) split="${2:-}"; shift 2 ;;
--split=*) split="${1#*=}"; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "replace: unknown option: $1" ;;
*) if [ -z "$gid" ]; then gid="$1"; else url="$1"; fi; shift ;;
esac
done
[ -n "$gid" ] && [ -n "$url" ] || err "replace: usage: pos network download replace <gid> <new-url>"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
dir="${dir/#\~/$HOME}"
ensure_daemon
gid=$(resolve_gid "$gid")
data=$(rpc aria2.tellStatus "$(json_str "$gid")")
st=$(printf '%s' "$data" | jq -r '.result.status')
case "$st" in
complete|error|removed) ;;
active) err "gid $gid is active — it's already downloading" ;;
*) err "gid $gid has status '$st' — cannot replace" ;;
esac
if [ "$(printf '%s' "$data" | jq -r 'if .result.bittorrent then 1 else 0 end')" = "1" ]; then
err "gid $gid is a torrent — a fresh URL can't replace it (use: restart $gid to re-add the same magnet)"
fi
nfiles=$(printf '%s' "$data" | jq -r '.result.files | length')
[ "$nfiles" -eq 1 ] || err "gid $gid has $nfiles files — replace supports single-file downloads only"
old_uri=$(printf '%s' "$data" | jq -r '.result.files[0].uris[0].uri // ""')
name=$(download_name "$data"); [ -n "$name" ] || name="download"
odir=$(printf '%s' "$data" | jq -r '.result.dir // ""')
d="${dir:-$odir}"
oa=()
[ -n "$d" ] && oa+=("dir=$d")
oa+=("out=$name")
[ -n "$split" ] && oa+=("split=$split")
newgid=$(rpc aria2.addUri "$(json_arr "$url")" "$(opts_json "${oa[@]}")" | jq -r '.result')
log "replaced $gid$newgid: $name — re-queued with fresh link (resumes partial file)"
[ -n "$old_uri" ] && retry_unmark "url:$old_uri"
retry_verify "$newgid" "url:$url" || err "replacement link also failed — see messages above"
ensure_healer
if [ "$tmux" -eq 1 ]; then tmux_watch "$newgid" "$name"; fi
}
retry_src_id() { # stable identity for the permanent-failure list (survives new gids)
local data
data=$(rpc aria2.tellStatus "$(json_str "$1")")
printf '%s' "$data" | jq -r 'if .result.bittorrent then "bt:" + .result.bittorrent.infoHash else "url:" + (.result.files[0].uris[0].uri // .result.files[0].path // "?") end'
}
retry_is_permanent() { grep -qxF "$1" "$RETRY_STATE" 2>/dev/null; }
retry_mark_permanent() {
mkdir -p "$CONFIG_DIR"
retry_is_permanent "$1" || printf '%s\n' "$1" >> "$RETRY_STATE"
}
retry_unmark() { # retry_unmark <src-id> — drop one source from the permanent list
[ -f "$RETRY_STATE" ] || return 0
grep -vxF "$1" "$RETRY_STATE" > "$RETRY_STATE.tmp" || true
mv "$RETRY_STATE.tmp" "$RETRY_STATE"
}
retry_verify() { # retry_verify <newgid> <src-id> → 0 ok / 1 failed (marks real failures permanent)
local g="$1" src="$2" attempt=0 st ec em data
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
sleep "$RETRY_VERIFY_SLEEP"
data=$(rpc aria2.tellStatus "$(json_str "$g")" 2>/dev/null || true)
st=$(printf '%s' "$data" | jq -r '.result.status // "?"')
case "$st" in
active|waiting) [ "${quiet:-0}" -eq 1 ] || log "retry running — $g (${g:0:8})"; return 0 ;;
complete) [ "${quiet:-0}" -eq 1 ] || log "retry finished instantly — $g already on disk"; return 0 ;;
error|removed)
ec=$(printf '%s' "$data" | jq -r '.result.errorCode // "?"')
em=$(printf '%s' "$data" | jq -r '.result.errorMessage // "?"')
if [ "$ec" = "3" ]; then
retry_mark_permanent "$src"
[ "${quiet:-0}" -eq 1 ] || err "real problem — source is gone (aria2 error 3: $em). Marked permanent; manual 'restart' overrides."
return 1
fi
[ "${quiet:-0}" -eq 1 ] || warn "retry attempt $attempt failed (aria2 error $ec: $em) — will re-check"
;;
esac
done
[ "${quiet:-0}" -eq 1 ] || warn "retry of $g did not stabilize after 5 checks"
return 1
}
cmd_retry() {
local target="all" interval="$RETRY_INTERVAL" maxwait=0 once=0 quiet=0
local rdir="" rsplit="" seed=0 tmux=0 rargs=() gids=() gid st failures=0 ng newgids src deadline=0
while [ $# -gt 0 ]; do
case "$1" in
--interval) interval="${2:-30}"; shift 2 ;;
--interval=*) interval="${1#*=}"; shift ;;
--max-wait) maxwait="${2:-0}"; shift 2 ;;
--max-wait=*) maxwait="${1#*=}"; shift ;;
--once) once=1; shift ;;
--quiet) quiet=1; shift ;;
--dir) rdir="${2:-}"; shift 2 ;;
--dir=*) rdir="${1#*=}"; shift ;;
--seed) seed=1; shift ;;
--split) rsplit="${2:-}"; shift 2 ;;
--split=*) rsplit="${1#*=}"; shift ;;
--tmux) tmux=1; shift ;;
-h|--help) usage ;;
-*) err "retry: unknown option: $1" ;;
*) target="$1"; shift ;;
esac
done
if ! daemon_active; then
[ "$quiet" -eq 1 ] && return 0
err "daemon not running — start it first (pos network download start)"
fi
rdir="${rdir/#\~/$HOME}"
[ "$tmux" -eq 1 ] && { command -v tmux &>/dev/null || err "tmux not found (install tmux) — needed for --tmux"; }
[ -n "$rdir" ] && rargs+=(--dir "$rdir")
[ "$seed" -eq 1 ] && rargs+=(--seed)
[ -n "$rsplit" ] && rargs+=(--split "$rsplit")
[ "$tmux" -eq 1 ] && rargs+=(--tmux)
if [ "$target" = "all" ]; then
mapfile -t gids < <(rpc aria2.tellStopped 0 200 | jq -r '.result[] | select(.status == "error") | .gid')
else
gid=$(resolve_gid "$target")
st=$(rpc aria2.tellStatus "$(json_str "$gid")" | jq -r '.result.status')
[ "$st" = "error" ] || err "gid $target has status '$st' — nothing to retry"
gids+=("$gid")
fi
if [ ${#gids[@]} -eq 0 ]; then
[ "$quiet" -eq 1 ] || log "nothing to retry — queue clean"
healer_done
return 0
fi
[ "$maxwait" -gt 0 ] && deadline=$((SECONDS + maxwait))
while ! net_up; do
if [ "$once" -eq 1 ]; then
[ "$quiet" -eq 1 ] || log "no internet — will retry on the next run"
return 0
fi
[ "$quiet" -eq 1 ] || log "no internet — waiting for connectivity (checking every ${interval}s, Ctrl+C to abort)"
[ "$maxwait" -gt 0 ] && [ "$SECONDS" -ge "$deadline" ] && err "timed out waiting for internet ($maxwait s)"
sleep "$interval"
done
for gid in "${gids[@]}"; do
src=$(retry_src_id "$gid")
if retry_is_permanent "$src"; then
[ "$quiet" -eq 1 ] || log "skip $gid — source marked permanently failing (override: pos network download restart $gid)"
continue
fi
[ "$quiet" -eq 1 ] || log "retrying ${gid:0:8}…"
newgids=$(do_restart "$gid" "$rdir" "$seed" "$rsplit" || true)
[ -n "$newgids" ] || { failures=$((failures + 1)); continue; }
ng=$(printf '%s\n' "$newgids" | head -1)
retry_verify "$ng" "$src" || failures=$((failures + 1))
done
healer_done
[ "$quiet" -eq 1 ] && return 0
[ "$failures" -eq 0 ] || err "$failures retry(s) failed — see messages above"
}
install_healer_units() {
mkdir -p "$USER_SYSTEMD_DIR"
local runner
if [ -x /usr/local/bin/pos-network-download ]; then
runner=/usr/local/bin/pos-network-download
else
runner="$(cd "$(dirname "$0")/.." && pwd)/bin/pos-network-download"
warn "using repo path $runner — re-run 'install.sh' so the healer survives a deleted repo"
fi
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) install $HEALER_SERVICE + $HEALER_TIMER in $USER_SYSTEMD_DIR"
return 0
fi
cat > "$USER_SYSTEMD_DIR/$HEALER_SERVICE" <<EOF
[Unit]
Description=pos aria2 retry healer (linux-post-install)
[Service]
Type=oneshot
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=$runner retry all --once --quiet
TimeoutStopSec=5s
EOF
chmod 644 "$USER_SYSTEMD_DIR/$HEALER_SERVICE"
cat > "$USER_SYSTEMD_DIR/$HEALER_TIMER" <<EOF
[Unit]
Description=pos aria2 retry healer timer (linux-post-install)
[Timer]
OnUnitActiveSec=2min
OnBootSec=2min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$USER_SYSTEMD_DIR/$HEALER_TIMER"
systemctl --user daemon-reload
}
ensure_healer() {
# Arm the retry timer whenever a download starts; it heals failed downloads.
if ! systemctl --user is-enabled "$HEALER_TIMER" >/dev/null 2>&1; then
install_healer_units
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) systemctl --user enable --now $HEALER_TIMER"
else
systemctl --user enable --now "$HEALER_TIMER" >/dev/null 2>&1 \
|| warn "could not enable retry healer ($HEALER_TIMER)"
log "retry healer armed — failed downloads auto-retry every 2 min"
fi
fi
}
healer_done() {
# Disable the timer when nothing is left to heal (no active/waiting/errored).
[ -f "$USER_SYSTEMD_DIR/$HEALER_TIMER" ] || return 0
local n
n=$(rpc aria2.tellActive | jq '.result | length')
[ "$n" -gt 0 ] && return 0
n=$(rpc aria2.tellWaiting 0 200 | jq '.result | length')
[ "$n" -gt 0 ] && return 0
n=$(rpc aria2.tellStopped 0 200 | jq '[.result[] | select(.status == "error")] | length')
[ "$n" -gt 0 ] && return 0
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) systemctl --user disable --now $HEALER_TIMER"
else
systemctl --user disable --now "$HEALER_TIMER" >/dev/null 2>&1 || true
log "retry healer disabled — no active or failed downloads left"
fi
}
# ── Listing / inspection ───────────────────────────────────────
LIST_JQ='.result[] | [.gid[0:8], .status,
(if ((.totalLength|tonumber) > 0) then (100*(.completedLength|tonumber)/(.totalLength|tonumber)) else 0 end),
(.downloadSpeed|tonumber), (.uploadSpeed|tonumber),
(.bittorrent.info.name // ((.files[0].path // "")|split("/")|last))] | @tsv'
fmt_list() {
awk -F'\t' "$ROW_AWK"'{ printf " %-8s %-9s %6.1f%% %9s %9s %s\n", $1, $2, $3, hs($4), hs($5), $6 }'
}
cmd_list() {
local act wait stp n
act=$(rpc aria2.tellActive)
wait=$(rpc aria2.tellWaiting 0 100)
stp=$(rpc aria2.tellStopped 0 50)
n=$(printf '%s' "$act" | jq '.result | length')
printf 'ACTIVE (%s)\n' "$n"
printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME
printf '%s' "$act" | jq -r "$LIST_JQ" | fmt_list
n=$(printf '%s' "$wait" | jq '.result | length')
printf 'WAITING (%s)\n' "$n"
printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME
printf '%s' "$wait" | jq -r "$LIST_JQ" | fmt_list
n=$(printf '%s' "$stp" | jq '.result | length')
printf 'STOPPED (last %s)\n' "$n"
printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME
printf '%s' "$stp" | jq -r "$LIST_JQ" | fmt_list
}
cmd_info() {
local gid="${1:-}"
[ -n "$gid" ] || err "info: gid required (see 'pos network download list')"
rpc aria2.tellStatus "$(json_str "$gid")" | jq -r '
.result as $r |
"gid: \($r.gid)",
"status: \($r.status)",
(if (($r.totalLength|tonumber) > 0) then "progress: \(100*($r.completedLength|tonumber)/($r.totalLength|tonumber)|floor)% \($r.completedLength)/\($r.totalLength) bytes" else "progress: unknown (metadata still downloading)" end),
"dl speed: \($r.downloadSpeed) B/s",
"up speed: \($r.uploadSpeed) B/s",
"connections: \($r.connections)",
(if ($r.numSeeders // 0 | tonumber) > 0 then "seeders: \($r.numSeeders)" else empty end),
(if ($r.errorCode | tonumber) > 0 then "error: \($r.errorCode) — \($r.errorMessage // "?")" else empty end),
(if $r.bittorrent then "torrent: \($r.bittorrent.info.name // "?")" + "\n" + "info-hash: \($r.infoHash)" else empty end),
"dir: \($r.dir)",
"files:",
($r.files[] | " [\(.index)] \(.path) \(.completedLength)/\(.length) bytes")'
}
cmd_files() {
local gid="${1:-}"
[ -n "$gid" ] || err "files: gid required"
rpc aria2.getFiles "$(json_str "$gid")" | jq -r '.result[] | [.index, .path, .length, .completedLength] | @tsv' \
| awk -F'\t' '{ printf " [%s] %s %s/%s bytes\n", $1, $2, $4, $3 }'
}
cmd_peers() {
local gid="${1:-}"
[ -n "$gid" ] || err "peers: gid required"
rpc aria2.getPeers "$(json_str "$gid")" | jq -r '.result[] | [.peerId[0:12], .ip, (.port|tostring), .downloadSpeed, .uploadSpeed, ((.progress|tonumber) * 100)] | @tsv' \
| awk -F'\t' "$ROW_AWK"'{ printf " %-12s %-16s %6s dl %s up %s %5.1f%%\n", $1, $2, $3, hs($4), hs($5), $6 }'
}
# ── Queue control ──────────────────────────────────────────────
queue_cmd() {
local single="$1" all="$2" verb="$3" target="${4:-all}"
if [ "$target" = "all" ]; then
rpc "$all" >/dev/null
else
rpc "$single" "$(json_str "$target")" >/dev/null
fi
log "$verb: $target"
}
cmd_pause() { queue_cmd aria2.pause aria2.pauseAll "paused" "$@"; }
cmd_resume() { queue_cmd aria2.unpause aria2.unpauseAll "resumed" "$@"; }
cmd_remove() {
local force=0 target="${1:-all}"
if [ "${1:-}" = "--force" ]; then force=1; target="${2:-all}"; fi
if [ "$target" = "all" ]; then
if [ "$force" -eq 1 ]; then rpc aria2.forceRemoveAll >/dev/null; else rpc aria2.removeAll >/dev/null; fi
else
if [ "$force" -eq 1 ]; then rpc aria2.forceRemove "$(json_str "$target")" >/dev/null; else rpc aria2.remove "$(json_str "$target")" >/dev/null; fi
fi
log "removed: $target"
}
cmd_purge() { rpc aria2.purgeDownloadResult >/dev/null; log "download history purged"; }
cmd_move() {
local gid="${1:-}" pos="${2:-}"
[ -n "$gid" ] && [ -n "$pos" ] || err "move: usage: pos network download move <gid> <position>"
local newpos
newpos=$(rpc aria2.changePosition "$(json_str "$gid")" "$(json_str "$pos")" "$(json_str "POS_SET")" | jq -r '.result')
log "moved $gid to position $newpos"
}
# ── Tuning ─────────────────────────────────────────────────────
cmd_limit() {
local upload=0 gid="" speed=""
while [ $# -gt 0 ]; do
case "$1" in
--upload) upload=1; shift ;;
-h|--help) usage ;;
-*) err "limit: unknown option: $1" ;;
*) if [ -z "$speed" ]; then speed="$1"; else gid="$speed"; speed="$1"; fi; shift ;;
esac
done
[ -n "$speed" ] || err "limit: speed required (0 = unlimited; e.g. 2M)"
local key="max-overall-download-limit"
if [ "$upload" -eq 1 ]; then key="max-overall-upload-limit"; fi
if [ -n "$gid" ]; then
key="max-download-limit"; [ "$upload" -eq 1 ] && key="max-upload-limit"
rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "$key=$speed")" >/dev/null
log "limit: $gid $key=$speed"
else
rpc aria2.changeGlobalOption "$(opts_json "$key=$speed")" >/dev/null
log "limit: global $key=$speed"
fi
}
cmd_set() {
local gid="" kvs=()
while [ $# -gt 0 ]; do
case "$1" in
--gid) gid="${2:-}"; shift 2 ;;
--gid=*) gid="${1#*=}"; shift ;;
-h|--help) usage ;;
-*) err "set: unknown option: $1" ;;
*) kvs+=("$1"); shift ;;
esac
done
[ ${#kvs[@]} -gt 0 ] || err "set: need key=value (see: aria2c --help=#rpc-options)"
local opts
opts=$(opts_json "${kvs[@]}")
if [ -n "$gid" ]; then
rpc aria2.changeOption "$gid" "$opts" >/dev/null
log "set: gid $gid${kvs[*]}"
else
rpc aria2.changeGlobalOption "$opts" >/dev/null
log "set: global — ${kvs[*]}"
fi
}
# ── Live view ──────────────────────────────────────────────────
cmd_watch() {
local gid="${1:-}"
printf '\033[?25l'
trap 'st=$?; printf "\033[?25h"; exit "$st"' INT TERM EXIT
if [ -n "$gid" ]; then
while :; do
local raw row st ng
raw=$(rpc aria2.tellStatus "$(json_str "$gid")")
row=$(printf '%s' "$raw" | jq -r '
.result as $r |
[$r.status,
(if (($r.totalLength|tonumber) > 0) then (100*($r.completedLength|tonumber)/($r.totalLength|tonumber)) else 0 end),
$r.completedLength, $r.totalLength,
($r.downloadSpeed|tonumber), ($r.uploadSpeed|tonumber),
($r.connections|tonumber),
(if (($r.downloadSpeed|tonumber) > 0) then (((($r.totalLength|tonumber) - ($r.completedLength|tonumber)) / ($r.downloadSpeed|tonumber))) else 0 end)]
| @tsv' 2>/dev/null || true)
st=$(printf '%s' "$row" | cut -f1)
printf '\r\033[J'
if [ -n "$row" ]; then
printf '%s' "$row" | awk -F'\t' "$ROW_AWK"'{ printf " %-9s %6.1f%% %s / %s dl %s up %s conn %s eta %s", $1, $2, $3, $4, hs($5), hs($6), $7, et($8) }'
fi
case "$st" in
complete) printf '\n'; exit 0 ;;
error|removed)
if net_up; then
printf '\n'
err "download $gid failed while internet is up — real problem (diagnose: pos network download info $gid; retry: pos network download retry $gid)"
fi
printf '\033[J network down — waiting for connectivity (auto-retry when back)\n'
while ! net_up; do sleep "$RETRY_INTERVAL"; done
printf '\r\033[J network back — restarting download\n'
gid=$(resolve_gid "$gid")
ng=$(do_restart "$gid" "" 0 "" | head -1)
[ -n "$ng" ] || exit 1
name=$(download_name "$(rpc aria2.tellStatus "$(json_str "$gid")")")
[ -n "$name" ] || name="download"
log "auto-restarted $gid$ng: $name"
gid="$ng"
;;
esac
sleep 2
done
else
while :; do
local act g n
act=$(rpc aria2.tellActive)
g=$(rpc aria2.getGlobalStat)
n=$(printf '%s' "$act" | jq '.result | length')
printf '\r\033[J'
if [ "$n" -eq 0 ]; then
printf 'no active downloads\n'
exit 0
fi
printf '%s' "$g" | jq -r '[.result.numActive, (.result.downloadSpeed|tonumber), (.result.uploadSpeed|tonumber)] | @tsv' | awk -F'\t' "$ROW_AWK"'{ printf "active %s dl %s up %s", $1, hs($2), hs($3) }'
printf ' %-8s %-9s %7s %9s %9s %s\n' GID STATUS PCT DL UL NAME
printf '%s' "$act" | jq -r "$LIST_JQ" | fmt_list
sleep 2
done
fi
}
tmux_watch() {
local gid="$1" name="$2"
name=$(printf '%s' "$name" | tr -cs 'A-Za-z0-9._-' '-' | cut -c1-40)
name="${name%-}"
[ -n "$name" ] || name="download"
local base sname n=2
base="dl-$name"
sname="$base"
while tmux has-session -t "$sname" 2>/dev/null; do
sname="${base}-${n}"; n=$((n+1))
done
local self
self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) tmux new-session -d -s $sname -- $self watch $gid"
else
TERM="${TERM:-xterm-256color}" tmux new-session -d -s "$sname" "$self watch $gid"
fi
log "tmux session '$sname' started — attach: tmux attach -t '$sname'"
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
# Top-verb map over the existing cmd_* implementations — picks/prompts/gates
# only, no new RPC logic. Queue views are gated on a non-fatal RPC liveness
# probe first: rpc() itself err-exits, so a dead daemon must be caught before
# it can end the menu; the graceful hint points at the start-daemon item.
# This tool deliberately stays OUTSIDE the dispatcher's INTERACTIVE_CMDS (its
# verbs are pipe-friendly one-shots that keep their tee logs), so every prompt
# here is a lib/menu-lib.sh primitive behind menu_guard's tty proof — no raw
# stdin-read helpers.
menu_ask_yn() { # $1 = question · rc 0 iff answered yes (default n; EOF cancels)
local ans
ans="$(menu_ask_value "$1" "N")" || return 1
[[ "$ans" =~ ^[Yy] ]]
}
menu_rpc_ok() { # cheap non-fatal liveness probe (same request shape as rpc())
curl -fsS --noproxy '*' -m 3 -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"aria2.getVersion\",\"params\":[\"token:${RPC_SECRET}\"]}" \
"$RPC_URL" >/dev/null 2>&1
}
menu_gate_daemon() { # rc 0 iff the RPC answers · else graceful pointer
if menu_rpc_ok; then return 0; fi
warn "aria2 RPC unreachable on $RPC_URL — start the daemon first (menu item below, or 'pos network download start')"
return 1
}
menu_list_gids() { # "full_gid<tab>status<tab>name" for active + waiting + stopped
{ rpc aria2.tellActive; rpc aria2.tellWaiting 0 200; rpc aria2.tellStopped 0 200; } \
| jq -r --arg na '?' '.result[]? |
[ .gid, .status,
(.bittorrent.info.name // (.files[0].path // $na | split("/") | last)) ] | @tsv'
}
menu_pick_gid_row() { # $1 = prompt → "<gid><tab>label" on stdout · rc 1 = cancelled / none
local -a rows=() gids=() labels=()
mapfile -t rows < <(menu_list_gids)
[ ${#rows[@]} -gt 0 ] || { warn "queue is empty — nothing to pick"; return 1; }
local r
for r in "${rows[@]}"; do
gids+=("${r%%$'\t'*}")
labels+=("$(printf '%s' "$r" | cut -f2- | tr '\t' ' ')")
done
local idx
idx="$(menu_pick "$1" "${labels[@]}")" || return 1
printf '%s\t%s\n' "${gids[$((idx - 1))]}" "${labels[$((idx - 1))]}"
}
menu_pick_gid() { # $1 = prompt → full gid on stdout · rc 1 = cancelled / none
local row
row="$(menu_pick_gid_row "$1")" || return 1
printf '%s\n' "${row%%$'\t'*}"
}
menu_gid_action() { # $1 = info|pause|resume|restart — pick a download, run the verb
local verb="$1" gid
menu_gate_daemon || return 0
gid="$(menu_pick_gid "$verb which download?")" || return 0
"cmd_$verb" "$gid"
}
menu_download_add() { # ask for a URL, hand cmd_add the existing flags
local url
url="$(menu_ask_value "URL to add (download dir: $DOWNLOAD_DIR)")" || return 0
[ -n "$url" ] || return 0
if menu_ask_yn "Hand live progress to a tmux session (--tmux)?"; then
cmd_add --tmux "$url"
else
log "(watch it later with: pos network download watch)"
cmd_add "$url"
fi
}
menu_download_remove() {
menu_gate_daemon || return 0
local row gid label name
row="$(menu_pick_gid_row "Remove which download?")" || return 0
gid="${row%%$'\t'*}"
label="${row#*$'\t'}"
name="${label#* }"
menu_ask_yn "Remove download '$name' (${gid:0:8})? Its progress is discarded." \
|| { log "Cancelled — kept"; return 0; }
cmd_remove "$gid"
}
menu_purge() {
menu_gate_daemon || return 0
warn "Purge clears ALL finished/error history from aria2."
local word
word="$(menu_ask_value "Type purge to clear finished/error history")" || return 0
[ "$word" = "purge" ] || { log "Cancelled — history kept"; return 0; }
cmd_purge
}
menu_daemon_stop() {
menu_ask_yn "Stop the aria2 daemon ($SERVICE)? Active downloads pause until it runs again." \
|| { log "Cancelled"; return 0; }
cmd_stop
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Downloads — aria2 RPC queue" \
"Daemon status" \
"Overview — status + queue snapshot" \
"List downloads" \
"Add a download URL" \
"Download details (info)" \
"Pause a download" \
"Resume a download" \
"Remove a download" \
"Re-queue / restart a download" \
"Purge finished/error history (type purge)" \
"Watch live progress (Ctrl-C leaves the menu)" \
"Start the daemon" \
"Stop the daemon")" || return 0
case "$choice" in
1) cmd_status ;;
2) menu_gate_daemon && overview ;;
3) menu_gate_daemon && cmd_list ;;
4) menu_download_add ;;
5) menu_gid_action info ;;
6) menu_gid_action pause ;;
7) menu_gid_action resume ;;
8) menu_download_remove ;;
9) menu_gid_action restart ;;
10) menu_purge ;;
11) menu_gate_daemon && cmd_watch ;;
12) cmd_start ;;
13) menu_daemon_stop ;;
esac
done
}
# ── Dispatch ───────────────────────────────────────────────────
overview() {
cmd_status
if daemon_active; then echo; cmd_list; fi
}
main() {
local cmd="${1:-}"
shift 2>/dev/null || true
case "$cmd" in
"") overview ;;
-h|--help) usage ;;
start) cmd_start ;;
stop) cmd_stop ;;
status) cmd_status ;;
add) cmd_add "$@" ;;
torrent) cmd_torrent "$@" ;;
metalink) cmd_metalink "$@" ;;
list) cmd_list ;;
info|files|peers|pause|resume|remove|move|limit|set|watch|restart|retry|replace)
"cmd_$cmd" "$@" ;;
purge) cmd_purge ;;
*) err "unknown command: $cmd (see 'pos network download --help')" ;;
esac
}
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI; scripted verbs (and the healer timer's `retry … --once`)
# never enter the menu.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
main "$@"