2 Commits

Author SHA1 Message Date
andrew 24738d815f 0.6.7: Drop slskd from the digest health check
slskd is not part of the pipeline -- sldl is its own Soulseek client and
nothing in alembic calls slskd's API; the digest's reachability ping was its
only mention. It keeps running on the host purely as the user's own
file-sharing presence, so checking it here just risked a permanent bogus
warning line for something alembic doesn't depend on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:50:20 -06:00
andrew 900bbff8aa 0.6.6: Digest format final: plain reflowing rows instead of <pre> columns
Iterating on the 0.6.5 HTML digest on a real phone: <pre> column layout
forces fixed-width lines that wrap into unreadable fragments ("54 tracks
in / M3U"), and one <pre> 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 <noreply@anthropic.com>
2026-07-15 13:44:26 -06:00
2 changed files with 74 additions and 61 deletions
+3 -3
View File
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
Pull the prebuilt image onto your Docker host: Pull the prebuilt image onto your Docker host:
```bash ```bash
docker pull git.kretzer.club/andrew/alembic:0.6.5 docker pull git.kretzer.club/andrew/alembic:0.6.7
``` ```
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.7` 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.) (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 ```yaml
services: services:
alembic: alembic:
image: git.kretzer.club/andrew/alembic:0.6.5 image: git.kretzer.club/andrew/alembic:0.6.7
container_name: alembic container_name: alembic
ports: ports:
- "8420:8420" - "8420:8420"
+70 -57
View File
@@ -4,20 +4,24 @@
# Writes a one-screen summary to ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log (overwritten daily) # Writes a one-screen summary to ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log (overwritten daily)
# and ships it to Telegram via notify-telegram.sh. # and ships it to Telegram via notify-telegram.sh.
# #
# Formatting note: the digest now uses Telegram HTML entities (<b>, <i>, <pre>, # Formatting note: the digest uses Telegram HTML entities (<b>, <i>, <code>,
# <blockquote>) — the brand's chrome/dot-badge language translated into what # <blockquote>) — the brand's chrome/dot-badge language translated into what
# Telegram can actually render (no color, no custom font, so status "dots" # Telegram can actually render (no color, no custom font, so status "dots"
# become colored circle emoji and section cards become bold header + <pre> # become colored circle emoji). Deliberately NO <pre>/monospace column layout:
# block, matching the web UI's h2 + mono table pattern). This REQUIRES # a code block forces fixed-width columns that wrap into unreadable garbage on
# notify-telegram.sh to be called with --html (parse_mode=HTML) — sent as # a phone ("54 tracks in / M3U"). Instead every row is plain reflowing text —
# plain text the tags would show up literally. # a bold label + " — value" — so it wraps cleanly at any screen width. This
# All free-text going through mark_ok/mark_warn/mark_skip/mark_info/section is # REQUIRES notify-telegram.sh to be called with --html (parse_mode=HTML) —
# escaped (esc()) for &, <, > so a stray angle bracket in a log line can't # sent as plain text the tags would show up literally. All free-text going
# break the HTML parse and swallow the whole message. # 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: # Reports on:
# - Sibling service reachability (slskd, navidrome) via HTTP, not docker ps — # - Sibling service reachability (navidrome) via HTTP, not docker ps —
# alembic has no Docker socket access # alembic has no Docker socket access. slskd is deliberately NOT checked:
# it is not part of the pipeline (sldl is its own Soulseek client) — it
# runs on the host purely as the user's own file-sharing presence.
# - Today's runs: playlist syncs, Bandcamp sync, manual imports, dedup # - Today's runs: playlist syncs, Bandcamp sync, manual imports, dedup
# - Weekly maintenance freshness: strip-mb-tags, strip-watermark-art, # - Weekly maintenance freshness: strip-mb-tags, strip-watermark-art,
# scrub-watermark-text, clean-sldl-index, spotify-genre # scrub-watermark-text, clean-sldl-index, spotify-genre
@@ -57,7 +61,6 @@ ND_PASS="${ND_PASS:-}"
OK=0 OK=0
WARN=0 WARN=0
SKIP=0 SKIP=0
PRE_OPEN=0
# Escapes &, <, > so free text (log snippets, exception messages, filenames) # Escapes &, <, > so free text (log snippets, exception messages, filenames)
# can't be mistaken for an HTML entity by Telegram's parser. # can't be mistaken for an HTML entity by Telegram's parser.
@@ -69,20 +72,28 @@ esc() {
printf '%s' "$s" printf '%s' "$s"
} }
# Status lines double as the brand's badge-dot language (success/warning/muted) # One status row: a colored dot, a bold label, and (optionally) " — value".
# translated into Telegram's only color channel: emoji. # Plain proportional text — NO padding, NO monospace — so it reflows on mobile
mark_ok() { OK=$((OK+1)); printf " 🟢 %s\n" "$(esc "$*")"; } # instead of wrapping mid-column. The dot is the brand's badge-state color,
mark_warn() { WARN=$((WARN+1)); printf " 🟠 %s\n" "$(esc "$*")"; } # emoji being Telegram's only color channel.
mark_skip() { SKIP=$((SKIP+1)); printf " ⚪ %s\n" "$(esc "$*")"; } _row() {
local dot=$1 label=$2 detail=${3:-}
if [[ -n "$detail" ]]; then
printf "%s <b>%s</b> — %s\n" "$dot" "$(esc "$label")" "$(esc "$detail")"
else
printf "%s <b>%s</b>\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 "$*")"; } mark_info() { printf " %s\n" "$(esc "$*")"; }
# Bold section header (proportional text, reads like the web app's <h2>), # Bold section header (reads like the web app's <h2>). No <pre> — the rows
# followed by a <pre> block so the printf-padded columns below it actually # under it are plain reflowing text.
# line up in a fixed-width font. Closes the previous section's <pre> first.
section() { section() {
if [[ "$PRE_OPEN" -eq 1 ]]; then printf "</pre>\n"; fi printf "\n<b>▎ %s</b>\n" "$(esc "$*")"
printf "\n<b>▎ %s</b>\n<pre>" "$(esc "$*")"
PRE_OPEN=1
} }
# syslog / `logger` is not present in the container image. Only call it if it # syslog / `logger` is not present in the container image. Only call it if it
@@ -114,7 +125,7 @@ playlist_status() {
m3u=$(awk 'match($0, /updated with [0-9]+ tracks/) { m3u=$(awk 'match($0, /updated with [0-9]+ tracks/) {
n=substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", n); last=n n=substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", n); last=n
} END { print last }' "$log") } 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 elif grep -q "=== Starting playlist run" "$log" 2>/dev/null; then
echo "WARN|started but never finished" echo "WARN|started but never finished"
else else
@@ -174,12 +185,11 @@ dedup_today_status() {
check_http() { check_http() {
local name="$1" url="$2" local name="$1" url="$2"
if curl -s -o /dev/null --connect-timeout 3 --max-time 6 "$url"; then 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 else
mark_warn "$(printf '%-11s unreachable at %s' "$name" "$url")" mark_warn "$name" "unreachable at $url"
fi fi
} }
check_http slskd "http://gluetun:5030/"
check_http navidrome "http://navidrome:4533/rest/ping.view" check_http navidrome "http://navidrome:4533/rest/ping.view"
# 2. Today's runs # 2. Today's runs
@@ -220,14 +230,12 @@ dedup_today_status() {
fi fi
;; ;;
esac esac
line=$(printf '%-13s %s' "$short" "$detail") if [[ "$tag" == "OK" ]]; then mark_ok "$short" "$detail"; else mark_warn "$short" "$detail"; fi
if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
done done
if [[ -n "$newest_dedup_today" ]]; then if [[ -n "$newest_dedup_today" ]]; then
any_today=1 any_today=1
IFS='|' read -r tag detail < <(dedup_today_status "$newest_dedup_today") IFS='|' read -r tag detail < <(dedup_today_status "$newest_dedup_today")
line=$(printf '%-13s %s' "dedup" "$detail") if [[ "$tag" == "OK" ]]; then mark_ok "dedup" "$detail"; else mark_warn "dedup" "$detail"; fi
if [[ "$tag" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
fi fi
[[ "$any_today" -eq 0 ]] && mark_info "(nothing has run yet today)" [[ "$any_today" -eq 0 ]] && mark_info "(nothing has run yet today)"
@@ -239,15 +247,15 @@ dedup_today_status() {
ADDED_TODAY=$(beet ls -f '$grouping' \ ADDED_TODAY=$(beet ls -f '$grouping' \
"added:$(date +%Y-%m-%d).." 2>/dev/null || true) "added:$(date +%Y-%m-%d).." 2>/dev/null || true)
added_total=$(echo -n "$ADDED_TODAY" | grep -c '^' || 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 if [[ "$added_total" -gt 0 ]]; then
# Per-playlist breakdown. Empty $grouping (bandcamp/manual) shown as "(none)". # Per-playlist breakdown. Empty $grouping (bandcamp/manual) shown as "(none)".
# Piped through the same &/</> escaping as mark_*, since this prints # Piped through the same &/</> escaping as esc(), since this prints
# straight into the <pre> block without going through esc() otherwise. # straight into the message without going through _row.
echo "$ADDED_TODAY" \ echo "$ADDED_TODAY" \
| awk '{ if ($0 == "") print "(none)"; else print }' \ | awk '{ if ($0 == "") print "(none)"; else print }' \
| sort | uniq -c | sort -rn \ | 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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'
fi fi
@@ -321,7 +329,7 @@ PYEOF
row=$(grep "^${task}|" <<< "$maint_rows" || true) row=$(grep "^${task}|" <<< "$maint_rows" || true)
IFS='|' read -r _ latest_status latest_age age_s log <<< "$row" IFS='|' read -r _ latest_status latest_age age_s log <<< "$row"
if [[ -z "$row" || "$latest_status" == "none" ]]; then if [[ -z "$row" || "$latest_status" == "none" ]]; then
mark_skip "$(printf '%-22s never run yet' "$task")" mark_skip "$task" "never run yet"
continue continue
fi fi
if [[ "$age_s" -lt 0 ]]; then if [[ "$age_s" -lt 0 ]]; then
@@ -331,7 +339,7 @@ PYEOF
running) note="running now" ;; running) note="running now" ;;
*) note="$latest_status" ;; *) note="$latest_status" ;;
esac 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 continue
fi fi
age_h=$(human_age "$age_s") age_h=$(human_age "$age_s")
@@ -369,11 +377,12 @@ PYEOF
failed) snip="${snip:+$snip, }latest attempt failed" ;; failed) snip="${snip:+$snip, }latest attempt failed" ;;
skipped_lock) snip="${snip:+$snip, }latest attempt skipped, lock busy" ;; skipped_lock) snip="${snip:+$snip, }latest attempt skipped, lock busy" ;;
esac 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 if (( age_s > MAINT_LIMIT[$task] )) || [[ "$latest_status" == "failed" ]]; then
mark_warn "$line" mark_warn "$task" "$detail"
else else
mark_ok "$line" mark_ok "$task" "$detail"
fi fi
done done
@@ -385,13 +394,16 @@ PYEOF
exp=$(awk -F'\t' '$6=="identity" {print $5; exit}' ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt) 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 if [[ -n "${exp:-}" && "$exp" =~ ^[0-9]+$ && "$exp" -gt 0 ]]; then
days=$(( (exp - NOW_TS) / 86400 )) days=$(( (exp - NOW_TS) / 86400 ))
line=$(printf '%-22s %d days left' "Bandcamp cookie" "$days") if (( days < 14 )); then
if (( days < 14 )); then mark_warn "$line — re-export soon"; else mark_ok "$line"; fi mark_warn "Bandcamp cookie" "$days days left — re-export soon"
else else
mark_warn "Bandcamp cookie could not parse expiry" mark_ok "Bandcamp cookie" "$days days left"
fi fi
else else
mark_warn "Bandcamp cookie missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)" mark_warn "Bandcamp cookie" "could not parse expiry"
fi
else
mark_warn "Bandcamp cookie" "missing (${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/cookies.txt)"
fi fi
# Qobuz Web Player token (buy-link enrich cascade #2). The token is opaque # Qobuz Web Player token (buy-link enrich cascade #2). The token is opaque
@@ -411,12 +423,12 @@ PYEOF
-H "X-App-Id: $q_appid" -H "X-User-Auth-Token: $q_token" -w '%{http_code}' \ -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) "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 case "$q_code" in
200) mark_ok "$(printf '%-22s %s' 'Qobuz token' 'valid (buy-link lookup live)')" ;; 200) mark_ok "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")" ;; 401) mark_warn "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})")" ;; *) mark_warn "Qobuz token" "check failed (HTTP ${q_code:-none})" ;;
esac esac
else 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 fi
# VPN egress # VPN egress
@@ -426,17 +438,20 @@ PYEOF
# asking Docker to introspect gluetun's health, and needs no Docker socket. # 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) egress_ip=$(curl -s --connect-timeout 5 --max-time 10 https://ifconfig.me 2>/dev/null)
if [[ -n "$egress_ip" ]]; then if [[ -n "$egress_ip" ]]; then
mark_ok "$(printf '%-22s %s' 'VPN egress' "$egress_ip")" mark_ok "VPN egress" "$egress_ip"
else 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 fi
# 5. Storage # 5. Storage
section "Storage" section "Storage"
while read -r mp pct used total; do while read -r mp pct used total; do
line=$(printf '%-22s %s used (%s of %s)' "$mp" "$pct" "$used" "$total")
pct_n=${pct%\%} 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}') done < <(df -h ${MUSIC_DATA_DIR:-/data/music} / 2>/dev/null | awk 'NR>1 && $1!~/tmpfs/ {print $6, $5, $3, $2}')
# 6. Navidrome # 6. Navidrome
@@ -461,15 +476,13 @@ except Exception as e:
print(f\"WARN|unreachable: {e}\") print(f\"WARN|unreachable: {e}\")
" 2>/dev/null) " 2>/dev/null)
state=${ND_LINE%%|*}; detail=${ND_LINE##*|} state=${ND_LINE%%|*}; detail=${ND_LINE##*|}
line=$(printf '%-22s %s' "Navidrome" "$detail") if [[ "$state" == "OK" ]]; then mark_ok "Navidrome" "$detail"; else mark_warn "Navidrome" "$detail"; fi
if [[ "$state" == "OK" ]]; then mark_ok "$line"; else mark_warn "$line"; fi
# 7. Library by format (info-only, no status pill) # 7. Library by format (info-only, no status dot)
section "Library by format" section "Library by format"
beet ls -f '$format' 2>/dev/null | sort | uniq -c | sort -rn \ beet ls -f '$format' 2>/dev/null | sort | uniq -c | sort -rn \
| awk '{printf " %-6s %d tracks\n", $2, $1}' | awk '{printf " %s — %d tracks\n", $2, $1}' \
| sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'
[[ "$PRE_OPEN" -eq 1 ]] && printf "</pre>\n"
} > "$OUT" } > "$OUT"