#!/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" 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 ''}" RETRY_INTERVAL="${RETRY_INTERVAL:-30}" RETRY_VERIFY_SLEEP="${RETRY_VERIFY_SLEEP:-3}" load_secret() { if [ -z "${RPC_SECRET:-}" ] && [ -f "$CONFIG_FILE" ]; then RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-) fi 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 < [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 [...] Enqueue HTTP/FTP downloads (auto-starts the daemon) torrent ... Enqueue .torrent files or magnet links metalink Enqueue a .metalink file or URL list Show active / waiting / finished downloads info Full status of one download files Files of a download peers 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 Reorder the waiting queue limit [gid] Speed limit, global or per-download (--upload = up; 0 = unlimited) set ... Set global aria2 options (--gid = per-download) watch [gid] Live progress, 2s refresh (Ctrl+C to detach) restart Re-queue a finished/errored download (resumes partial files; torrents re-add via magnet, HTTP via their original URLs) retry 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 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 download directory (default: $DOWNLOAD_DIR) --out output file name (add only) --split 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-) Options (retry): --interval poll seconds while waiting for internet (default: 30) --max-wait 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 ' 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 [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" ] && [ -f "$CONFIG_FILE" ]; then RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-) 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 mkdir -p "$USER_SYSTEMD_DIR" cat > "$USER_SYSTEMD_DIR/$SERVICE" </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 '. 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 \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 → name of the file / torrent printf '%s' "$1" | jq -r '(.result.bittorrent.info.name // (.result.files[0].path // "")) | split("/") | last' } do_restart() { # do_restart → 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 — 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 " [ "$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 — 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 → 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" < "$USER_SYSTEMD_DIR/$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 " 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_gidstatusname" 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 → "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 "$@"