From 900bbff8aa286f4b35ea2ae8a202087ee25cb85a Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 15 Jul 2026 13:44:26 -0600 Subject: [PATCH] 0.6.6: Digest format final: plain reflowing rows instead of
 columns

Iterating on the 0.6.5 HTML digest on a real phone: 
 column layout
forces fixed-width lines that wrap into unreadable fragments ("54 tracks
in / M3U"), and one 
 per section rendered as a stack of copy-button
widgets. Every row is now plain proportional text -- colored dot, bold
label, "-- value" -- which reflows cleanly at any screen width; breakdowns
(added-today per playlist, library by format) become bullet lists. Header
banner (brand line, dot tally, all-clear/N-issues blockquote) unchanged.

Co-Authored-By: Claude Fable 5 
---
 README.md                       |   6 +-
 pipeline/lib/pipeline-status.sh | 124 +++++++++++++++++---------------
 2 files changed, 71 insertions(+), 59 deletions(-)

diff --git a/README.md b/README.md
index 223a9f2..380b6c7 100644
--- a/README.md
+++ b/README.md
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
 Pull the prebuilt image onto your Docker host:
 
 ```bash
-docker pull git.kretzer.club/andrew/alembic:0.6.5
+docker pull git.kretzer.club/andrew/alembic:0.6.6
 ```
 
-That is the whole install. You do not need to download the source or build anything. The `0.6.5` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
+That is the whole install. You do not need to download the source or build anything. The `0.6.6` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
 
 (If you would rather build it yourself from source, you can, but you do not need to.)
 
@@ -136,7 +136,7 @@ Create a file called `docker-compose.yml` on your server (put it wherever you ke
 ```yaml
 services:
   alembic:
-    image: git.kretzer.club/andrew/alembic:0.6.5
+    image: git.kretzer.club/andrew/alembic:0.6.6
     container_name: alembic
     ports:
       - "8420:8420"
diff --git a/pipeline/lib/pipeline-status.sh b/pipeline/lib/pipeline-status.sh
index 53dba09..86f7ec7 100755
--- a/pipeline/lib/pipeline-status.sh
+++ b/pipeline/lib/pipeline-status.sh
@@ -4,16 +4,18 @@
 # Writes a one-screen summary to ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log (overwritten daily)
 # and ships it to Telegram via notify-telegram.sh.
 #
-# Formatting note: the digest now uses Telegram HTML entities (, , 
,
+# Formatting note: the digest uses Telegram HTML entities (, , ,
 # 
) — the brand's chrome/dot-badge language translated into what # Telegram can actually render (no color, no custom font, so status "dots" -# become colored circle emoji and section cards become bold header +
-# block, matching the web UI's h2 + mono table pattern). This REQUIRES
-# notify-telegram.sh to be called with --html (parse_mode=HTML) — sent as
-# plain text the tags would show up literally.
-# All free-text going through mark_ok/mark_warn/mark_skip/mark_info/section is
-# escaped (esc()) for &, <, > so a stray angle bracket in a log line can't
-# break the HTML parse and swallow the whole message.
+# become colored circle emoji). Deliberately NO 
/monospace column layout:
+# a code block forces fixed-width columns that wrap into unreadable garbage on
+# a phone ("54 tracks in / M3U"). Instead every row is plain reflowing text —
+# a bold label + " — value" — so it wraps cleanly at any screen width. This
+# REQUIRES notify-telegram.sh to be called with --html (parse_mode=HTML) —
+# sent as plain text the tags would show up literally. All free-text going
+# through mark_ok/mark_warn/mark_skip/section is escaped (esc()) for &, <, >
+# so a stray angle bracket in a log line can't break the HTML parse and
+# swallow the whole message.
 #
 # Reports on:
 #   - Sibling service reachability (slskd, navidrome) via HTTP, not docker ps —
@@ -57,7 +59,6 @@ ND_PASS="${ND_PASS:-}"
 OK=0
 WARN=0
 SKIP=0
-PRE_OPEN=0
 
 # Escapes &, <, > so free text (log snippets, exception messages, filenames)
 # can't be mistaken for an HTML entity by Telegram's parser.
@@ -69,20 +70,28 @@ esc() {
   printf '%s' "$s"
 }
 
-# Status lines double as the brand's badge-dot language (success/warning/muted)
-# translated into Telegram's only color channel: emoji.
-mark_ok()   { OK=$((OK+1));     printf "  🟢 %s\n" "$(esc "$*")"; }
-mark_warn() { WARN=$((WARN+1)); printf "  🟠 %s\n" "$(esc "$*")"; }
-mark_skip() { SKIP=$((SKIP+1)); printf "  ⚪ %s\n" "$(esc "$*")"; }
-mark_info() {                   printf "     %s\n" "$(esc "$*")"; }
+# One status row: a colored dot, a bold label, and (optionally) " — value".
+# Plain proportional text — NO padding, NO monospace — so it reflows on mobile
+# instead of wrapping mid-column. The dot is the brand's badge-state color,
+# emoji being Telegram's only color channel.
+_row() {
+  local dot=$1 label=$2 detail=${3:-}
+  if [[ -n "$detail" ]]; then
+    printf "%s %s — %s\n" "$dot" "$(esc "$label")" "$(esc "$detail")"
+  else
+    printf "%s %s\n" "$dot" "$(esc "$label")"
+  fi
+}
+mark_ok()   { OK=$((OK+1));     _row "🟢" "$@"; }
+mark_warn() { WARN=$((WARN+1)); _row "🟠" "$@"; }
+mark_skip() { SKIP=$((SKIP+1)); _row "⚪" "$@"; }
+# Free-text info line (no dot, no counter) — for sub-breakdowns.
+mark_info() { printf "   %s\n" "$(esc "$*")"; }
 
-# Bold section header (proportional text, reads like the web app's 

), -# followed by a
 block so the printf-padded columns below it actually
-# line up in a fixed-width font. Closes the previous section's 
 first.
+# Bold section header (reads like the web app's 

). No
 — the rows
+# under it are plain reflowing text.
 section() {
-  if [[ "$PRE_OPEN" -eq 1 ]]; then printf "
\n"; fi - printf "\n▎ %s\n
" "$(esc "$*")"
-  PRE_OPEN=1
+  printf "\n▎ %s\n" "$(esc "$*")"
 }
 
 # syslog / `logger` is not present in the container image. Only call it if it
@@ -114,7 +123,7 @@ playlist_status() {
     m3u=$(awk 'match($0, /updated with [0-9]+ tracks/) {
                  n=substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", n); last=n
                } END { print last }' "$log")
-    echo "OK|${m3u:-0} tracks in M3U"
+    echo "OK|${m3u:-0} tracks"
   elif grep -q "=== Starting playlist run" "$log" 2>/dev/null; then
     echo "WARN|started but never finished"
   else
@@ -174,12 +183,12 @@ dedup_today_status() {
   check_http() {
     local name="$1" url="$2"
     if curl -s -o /dev/null --connect-timeout 3 --max-time 6 "$url"; then
-      mark_ok "$(printf '%-11s reachable' "$name")"
+      mark_ok "$name" "reachable"
     else
-      mark_warn "$(printf '%-11s unreachable at %s' "$name" "$url")"
+      mark_warn "$name" "unreachable at $url"
     fi
   }
-  check_http slskd   "http://gluetun:5030/"
+  check_http slskd     "http://gluetun:5030/"
   check_http navidrome "http://navidrome:4533/rest/ping.view"
 
   # 2. Today's runs
@@ -220,14 +229,12 @@ dedup_today_status() {
         fi
         ;;
     esac
-    line=$(printf '%-13s %s' "$short" "$detail")
-    if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
+    if [[ "$tag" == "OK" ]]; then mark_ok "$short" "$detail"; else mark_warn "$short" "$detail"; fi
   done
   if [[ -n "$newest_dedup_today" ]]; then
     any_today=1
     IFS='|' read -r tag detail < <(dedup_today_status "$newest_dedup_today")
-    line=$(printf '%-13s %s' "dedup" "$detail")
-    if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
+    if [[ "$tag" == "OK" ]]; then mark_ok "dedup" "$detail"; else mark_warn "dedup" "$detail"; fi
   fi
   [[ "$any_today" -eq 0 ]] && mark_info "(nothing has run yet today)"
 
@@ -239,15 +246,15 @@ dedup_today_status() {
   ADDED_TODAY=$(beet ls -f '$grouping' \
     "added:$(date +%Y-%m-%d).." 2>/dev/null || true)
   added_total=$(echo -n "$ADDED_TODAY" | grep -c '^' || true)
-  mark_ok "$(printf '%-13s %d new tracks in beets' 'added today' "$added_total")"
+  mark_ok "added today" "$added_total new tracks in beets"
   if [[ "$added_total" -gt 0 ]]; then
     # Per-playlist breakdown. Empty $grouping (bandcamp/manual) shown as "(none)".
-    # Piped through the same &/ escaping as mark_*, since this prints
-    # straight into the 
 block without going through esc() otherwise.
+    # Piped through the same &/ escaping as esc(), since this prints
+    # straight into the message without going through _row.
     echo "$ADDED_TODAY" \
       | awk '{ if ($0 == "") print "(none)"; else print }' \
       | sort | uniq -c | sort -rn \
-      | awk '{ g=$2; for(i=3;i<=NF;i++) g=g" "$i; printf "                  %3d  %s\n", $1, g }' \
+      | awk '{ c=$1; g=$2; for(i=3;i<=NF;i++) g=g" "$i; printf "   • %s (%d)\n", g, c }' \
       | sed 's/&/\&/g; s//\>/g'
   fi
 
@@ -321,7 +328,7 @@ PYEOF
     row=$(grep "^${task}|" <<< "$maint_rows" || true)
     IFS='|' read -r _ latest_status latest_age age_s log <<< "$row"
     if [[ -z "$row" || "$latest_status" == "none" ]]; then
-      mark_skip "$(printf '%-22s never run yet' "$task")"
+      mark_skip "$task" "never run yet"
       continue
     fi
     if [[ "$age_s" -lt 0 ]]; then
@@ -331,7 +338,7 @@ PYEOF
         running)      note="running now" ;;
         *)            note="$latest_status" ;;
       esac
-      mark_warn "$(printf '%-22s never succeeded (last attempt %s: %s)' "$task" "$(human_age "$latest_age")" "$note")"
+      mark_warn "$task" "never succeeded (last attempt $(human_age "$latest_age"): $note)"
       continue
     fi
     age_h=$(human_age "$age_s")
@@ -369,11 +376,12 @@ PYEOF
       failed)       snip="${snip:+$snip, }latest attempt failed" ;;
       skipped_lock) snip="${snip:+$snip, }latest attempt skipped, lock busy" ;;
     esac
-    line=$(printf '%-22s %-9s %s' "$task" "$age_h" "${snip:-}")
+    detail="$age_h"
+    [[ -n "$snip" ]] && detail="$age_h · $snip"
     if (( age_s > MAINT_LIMIT[$task] )) || [[ "$latest_status" == "failed" ]]; then
-      mark_warn "$line"
+      mark_warn "$task" "$detail"
     else
-      mark_ok "$line"
+      mark_ok "$task" "$detail"
     fi
   done
 
@@ -385,13 +393,16 @@ PYEOF
     exp=$(awk -F'\t' '$6=="identity" {print $5; exit}' ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)
     if [[ -n "${exp:-}" && "$exp" =~ ^[0-9]+$ && "$exp" -gt 0 ]]; then
       days=$(( (exp - NOW_TS) / 86400 ))
-      line=$(printf '%-22s %d days left' "Bandcamp cookie" "$days")
-      if (( days < 14 )); then mark_warn "$line — re-export soon"; else mark_ok "$line"; fi
+      if (( days < 14 )); then
+        mark_warn "Bandcamp cookie" "$days days left — re-export soon"
+      else
+        mark_ok "Bandcamp cookie" "$days days left"
+      fi
     else
-      mark_warn "Bandcamp cookie         could not parse expiry"
+      mark_warn "Bandcamp cookie" "could not parse expiry"
     fi
   else
-    mark_warn "Bandcamp cookie         missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)"
+    mark_warn "Bandcamp cookie" "missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)"
   fi
 
   # Qobuz Web Player token (buy-link enrich cascade #2). The token is opaque
@@ -411,12 +422,12 @@ PYEOF
       -H "X-App-Id: $q_appid" -H "X-User-Auth-Token: $q_token" -w '%{http_code}' \
       "https://www.qobuz.com/api.json/0.2/track/search?query=test&limit=1&app_id=$q_appid" 2>/dev/null)
     case "$q_code" in
-      200) mark_ok   "$(printf '%-22s %s' 'Qobuz token' 'valid (buy-link lookup live)')" ;;
-      401) mark_warn "$(printf '%-22s %s' 'Qobuz token' "EXPIRED — re-export X-User-Auth-Token to ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token")" ;;
-      *)   mark_warn "$(printf '%-22s %s' 'Qobuz token' "check failed (HTTP ${q_code:-none})")" ;;
+      200) mark_ok   "Qobuz token" "valid (buy-link lookup live)" ;;
+      401) mark_warn "Qobuz token" "EXPIRED — re-export X-User-Auth-Token to ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token" ;;
+      *)   mark_warn "Qobuz token" "check failed (HTTP ${q_code:-none})" ;;
     esac
   else
-    mark_warn "$(printf '%-22s %s' 'Qobuz token' "missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)")"
+    mark_warn "Qobuz token" "missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)"
   fi
 
   # VPN egress
@@ -426,17 +437,20 @@ PYEOF
   # asking Docker to introspect gluetun's health, and needs no Docker socket.
   egress_ip=$(curl -s --connect-timeout 5 --max-time 10 https://ifconfig.me 2>/dev/null)
   if [[ -n "$egress_ip" ]]; then
-    mark_ok "$(printf '%-22s %s' 'VPN egress' "$egress_ip")"
+    mark_ok "VPN egress" "$egress_ip"
   else
-    mark_warn "$(printf '%-22s %s' 'VPN egress' 'could not determine egress IP — VPN may be down')"
+    mark_warn "VPN egress" "could not determine egress IP — VPN may be down"
   fi
 
   # 5. Storage
   section "Storage"
   while read -r mp pct used total; do
-    line=$(printf '%-22s %s used (%s of %s)' "$mp" "$pct" "$used" "$total")
     pct_n=${pct%\%}
-    if (( pct_n > 90 )); then mark_warn "$line"; else mark_ok "$line"; fi
+    if (( pct_n > 90 )); then
+      mark_warn "$mp" "$pct used ($used of $total)"
+    else
+      mark_ok "$mp" "$pct used ($used of $total)"
+    fi
   done < <(df -h ${MUSIC_DATA_DIR:-/data/music} / 2>/dev/null | awk 'NR>1 && $1!~/tmpfs/ {print $6, $5, $3, $2}')
 
   # 6. Navidrome
@@ -461,15 +475,13 @@ except Exception as e:
     print(f\"WARN|unreachable: {e}\")
 " 2>/dev/null)
   state=${ND_LINE%%|*}; detail=${ND_LINE##*|}
-  line=$(printf '%-22s %s' "Navidrome" "$detail")
-  if [[ "$state" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
+  if [[ "$state" == "OK" ]]; then mark_ok "Navidrome" "$detail"; else mark_warn "Navidrome" "$detail"; fi
 
-  # 7. Library by format (info-only, no status pill)
+  # 7. Library by format (info-only, no status dot)
   section "Library by format"
   beet ls -f '$format' 2>/dev/null | sort | uniq -c | sort -rn \
-    | awk '{printf "    %-6s %d tracks\n", $2, $1}'
-
-  [[ "$PRE_OPEN" -eq 1 ]] && printf "
\n" + | awk '{printf " • %s — %d tracks\n", $2, $1}' \ + | sed 's/&/\&/g; s//\>/g' } > "$OUT"