0.6.5: HTML Telegram digest + fix silently-empty beet sections + watermark-art resilience
The daily digest is now formatted with Telegram HTML: bold section headers
with <pre>-aligned columns, colored circle emoji as status dots, a branded
header line, and a top blockquote callout (all clear vs N issues). All free
text is &/</> escaped so a stray angle bracket in a log snippet can't break
the parse and swallow the whole message. notify-telegram.sh gains an --html
flag (parse_mode=HTML) used by the digest send; plain-text callers are
unchanged. Truncation is now tag-safe in HTML mode: cut on a line boundary,
re-close an open <pre>, and use an escaped marker -- the old literal
"...<truncated>" tail would itself have 400'd the send.
Two real bugs surfaced while testing:
- pipeline-status.sh pins its own PATH, which lacked /opt/venv/bin where
beet lives, so every beet probe ("added today", "Library by format", the
mp3-now count) has been silently empty behind 2>/dev/null since the cron
migration. PATH now includes the venv; both sections show real numbers.
- strip-watermark-art.py aborted its entire weekly run when metaflac stalled
on ONE file (seen today: a healthy 190KB cover took >15s under disk
contention, TimeoutExpired killed the job). Per-file timeout is now 60s
and a timeout skips that file with a warning instead of failing the run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.4
|
||||
docker pull git.kretzer.club/andrew/alembic:0.6.5
|
||||
```
|
||||
|
||||
That is the whole install. You do not need to download the source or build anything. The `0.6.4` 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.5` 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.4
|
||||
image: git.kretzer.club/andrew/alembic:0.6.5
|
||||
container_name: alembic
|
||||
ports:
|
||||
- "8420:8420"
|
||||
|
||||
@@ -5,14 +5,29 @@
|
||||
# Usage:
|
||||
# echo "single line message" | notify-telegram.sh
|
||||
# notify-telegram.sh "single line message"
|
||||
# notify-telegram.sh < ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log
|
||||
# notify-telegram.sh --html < ${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log
|
||||
#
|
||||
# Telegram messages are capped at 4096 chars. Anything longer is truncated
|
||||
# with a "...<truncated>" tail. Returns exit 0 on send-OK, non-zero otherwise.
|
||||
# --html sends with parse_mode=HTML for messages authored as Telegram-HTML
|
||||
# (pipeline-status.sh's digest: <b>/<i>/<code>/<pre>/<blockquote>). The
|
||||
# CALLER is responsible for escaping &, <, > in any free text; this script
|
||||
# only guarantees the truncation below can't cut a tag in half. Without the
|
||||
# flag, messages go as plain text exactly as before.
|
||||
#
|
||||
# Telegram messages are capped at 4096 chars. Anything longer is truncated;
|
||||
# in HTML mode the cut lands on a line boundary and re-closes an open <pre>
|
||||
# so the truncated message still parses (a mid-tag cut, or a bare "<" in the
|
||||
# tail marker, makes the Bot API reject the ENTIRE message with a 400).
|
||||
# Returns exit 0 on send-OK, non-zero otherwise.
|
||||
|
||||
set -euo pipefail
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
PARSE_MODE=""
|
||||
if [[ "${1:-}" == "--html" ]]; then
|
||||
PARSE_MODE="HTML"
|
||||
shift
|
||||
fi
|
||||
|
||||
CONFIG="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/telegram/notify.env"
|
||||
if [[ ! -f "$CONFIG" ]]; then
|
||||
echo "[notify-telegram] no config at $CONFIG" >&2
|
||||
@@ -30,24 +45,37 @@ else
|
||||
MSG=$(cat)
|
||||
fi
|
||||
|
||||
# Telegram caps at 4096 chars (UTF-8 codepoints). Trim conservatively at 3900.
|
||||
# Use python for proper UTF-8 length handling.
|
||||
# Telegram caps at 4096 chars. Trim conservatively at 3900. Use python for
|
||||
# proper UTF-8 length handling. Plain mode appends a literal marker; HTML
|
||||
# mode cuts at the last newline inside the budget (our tags never span
|
||||
# lines except <pre> blocks) and re-closes an unbalanced <pre>.
|
||||
MSG=$(python3 -c "
|
||||
import sys
|
||||
s = sys.argv[1]
|
||||
html = sys.argv[2] == 'HTML'
|
||||
if len(s) > 3900:
|
||||
s = s[:3900] + '\n...<truncated>'
|
||||
s = s[:3900]
|
||||
if html:
|
||||
cut = s.rfind('\n')
|
||||
if cut > 0:
|
||||
s = s[:cut]
|
||||
if s.count('<pre>') > s.count('</pre>'):
|
||||
s += '</pre>'
|
||||
s += '\n<i>… truncated</i>'
|
||||
else:
|
||||
s += '\n...(truncated)'
|
||||
print(s, end='')
|
||||
" "$MSG")
|
||||
" "$MSG" "${PARSE_MODE:-plain}")
|
||||
|
||||
# Send via Bot API. Disable web-page preview and use plain text (no parse_mode)
|
||||
# so log content with special chars doesn't get interpreted as Markdown.
|
||||
# Send via Bot API. Preview disabled; parse_mode only when requested so log
|
||||
# content with special chars can't be misread as markup in plain sends.
|
||||
# `|| true`: a curl timeout/network error must fall through to the explicit
|
||||
# ok-check below (which reports "send failed" and exits 2), not abort here.
|
||||
resp=$(curl -s --max-time 15 -X POST \
|
||||
"https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MSG}" \
|
||||
${PARSE_MODE:+--data-urlencode "parse_mode=${PARSE_MODE}"} \
|
||||
--data-urlencode "disable_web_page_preview=true" || true)
|
||||
|
||||
ok=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin).get('ok',False))" 2>/dev/null || echo False)
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
# 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 (<b>, <i>, <pre>,
|
||||
# <blockquote>) — 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 + <pre>
|
||||
# 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.
|
||||
#
|
||||
# Reports on:
|
||||
# - Sibling service reachability (slskd, navidrome) via HTTP, not docker ps —
|
||||
# alembic has no Docker socket access
|
||||
@@ -27,7 +38,10 @@
|
||||
# the opposite of what a health report should do. It writes no library state,
|
||||
# so there is nothing to leave half-applied.
|
||||
set -u
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
# /opt/venv/bin first: `beet` lives in the app venv. Without it every beet
|
||||
# probe here ("added today", "Library by format", the mp3-now count) silently
|
||||
# came up empty behind its 2>/dev/null.
|
||||
PATH=/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
OUT=${ALEMBIC_CONFIG_DIR:-/config}/logs/STATUS.log
|
||||
TODAY=$(date +%Y%m%d)
|
||||
@@ -43,13 +57,33 @@ ND_PASS="${ND_PASS:-}"
|
||||
OK=0
|
||||
WARN=0
|
||||
SKIP=0
|
||||
PRE_OPEN=0
|
||||
|
||||
mark_ok() { OK=$((OK+1)); printf " ✓ %s\n" "$*"; }
|
||||
mark_warn() { WARN=$((WARN+1)); printf " ⚠ %s\n" "$*"; }
|
||||
mark_skip() { SKIP=$((SKIP+1)); printf " ⊘ %s\n" "$*"; }
|
||||
mark_info() { printf " %s\n" "$*"; }
|
||||
# Escapes &, <, > so free text (log snippets, exception messages, filenames)
|
||||
# can't be mistaken for an HTML entity by Telegram's parser.
|
||||
esc() {
|
||||
local s=$1
|
||||
s=${s//&/&}
|
||||
s=${s//</<}
|
||||
s=${s//>/>}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
section() { printf "\n▎ %s\n" "$*"; }
|
||||
# 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 "$*")"; }
|
||||
|
||||
# Bold section header (proportional text, reads like the web app's <h2>),
|
||||
# followed by a <pre> block so the printf-padded columns below it actually
|
||||
# line up in a fixed-width font. Closes the previous section's <pre> first.
|
||||
section() {
|
||||
if [[ "$PRE_OPEN" -eq 1 ]]; then printf "</pre>\n"; fi
|
||||
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
|
||||
# exists, so these lines don't spew "logger: command not found" into the job
|
||||
@@ -208,10 +242,13 @@ dedup_today_status() {
|
||||
mark_ok "$(printf '%-13s %d new tracks in beets' 'added today' "$added_total")"
|
||||
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 <pre> block without going through esc() otherwise.
|
||||
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 '{ g=$2; for(i=3;i<=NF;i++) g=g" "$i; printf " %3d %s\n", $1, g }' \
|
||||
| sed 's/&/\&/g; s/</\</g; s/>/\>/g'
|
||||
fi
|
||||
|
||||
# 3. Maintenance freshness
|
||||
@@ -432,16 +469,34 @@ except Exception as e:
|
||||
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 "</pre>\n"
|
||||
|
||||
} > "$OUT"
|
||||
|
||||
# ---- Build header banner and prepend ----
|
||||
# Bold brand mark (⚗️ is literally the "ALEMBIC" unicode glyph — no generic
|
||||
# music-note stand-in needed), a hostname chip in <code>, and an italic tally
|
||||
# that reuses the same 🟢/🟠/⚪ dots as the body. When something needs
|
||||
# attention, a <blockquote> callout mirrors the web app's .notice.warning
|
||||
# panel; an all-clear run gets the calm .notice.success equivalent instead.
|
||||
HOSTNAME_SHORT=$(hostname -s)
|
||||
DATE_HUMAN=$(TZ="${TZ:-UTC}" date '+%a %Y-%m-%d %H:%M %Z')
|
||||
BANNER_TITLE="🎵 Music pipeline · $HOSTNAME_SHORT · $DATE_HUMAN"
|
||||
BANNER_TALLY=" $OK OK · $WARN warn · $SKIP skip"
|
||||
sed -i "1c\\
|
||||
${BANNER_TITLE}\\
|
||||
${BANNER_TALLY}" "$OUT"
|
||||
HEADER="⚗️ <b>Alembic</b> · music pipeline · <code>${HOSTNAME_SHORT}</code> · ${DATE_HUMAN}
|
||||
<i>🟢 ${OK} OK 🟠 ${WARN} warn ⚪ ${SKIP} skip</i>"
|
||||
if (( WARN > 0 )); then
|
||||
HEADER="${HEADER}
|
||||
<blockquote>🟠 <b>${WARN} issue(s)</b> need attention — see below</blockquote>"
|
||||
else
|
||||
HEADER="${HEADER}
|
||||
<blockquote>🟢 All clear — nothing needs attention.</blockquote>"
|
||||
fi
|
||||
|
||||
# Swap the __HEADER_PLACEHOLDER__ line for the (2-3 line) banner above. A
|
||||
# straight prepend, not sed 1c — the banner's line count varies with the
|
||||
# blockquote, which sed's `c` command can't take as a variable easily.
|
||||
tail -n +2 "$OUT" > "${OUT}.body"
|
||||
{ printf '%s\n' "$HEADER"; cat "${OUT}.body"; } > "$OUT"
|
||||
rm -f "${OUT}.body"
|
||||
|
||||
# Always mirror the one-line summary to syslog so journalctl shows last-run
|
||||
# state even if Telegram is broken. Warnings get a separate WARN tag.
|
||||
@@ -450,10 +505,12 @@ if (( WARN > 0 )); then
|
||||
slog "WARN: pipeline-status flagged $WARN issue(s) — see $OUT"
|
||||
fi
|
||||
|
||||
# Send to Telegram. If it fails, log the failure to syslog so the absence of
|
||||
# a message in the chat has a corresponding journal entry to grep for.
|
||||
# Send to Telegram with --html: the digest is Telegram-HTML (see header
|
||||
# comment), so notify-telegram.sh must send it with parse_mode=HTML. If the
|
||||
# send fails, log to syslog so the absence of a message in the chat has a
|
||||
# corresponding journal entry to grep for.
|
||||
if [[ -x ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh ]]; then
|
||||
if ! ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh < "$OUT" 2>/tmp/tg-err; then
|
||||
if ! ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh --html < "$OUT" 2>/tmp/tg-err; then
|
||||
slog "WARN: Telegram send failed: $(cat /tmp/tg-err 2>/dev/null | head -c 200)"
|
||||
rm -f /tmp/tg-err
|
||||
fi
|
||||
|
||||
@@ -51,10 +51,17 @@ def extract_flac_picture(flac_path):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pic") as tf:
|
||||
tmp = tf.name
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["metaflac", f"--export-picture-to={tmp}", flac_path],
|
||||
capture_output=True, timeout=15
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["metaflac", f"--export-picture-to={tmp}", flac_path],
|
||||
capture_output=True, timeout=60
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# A transient IO stall on one file must not abort the whole weekly
|
||||
# run (seen 2026-07-15: a healthy 190KB cover took >15s under disk
|
||||
# contention and the raised TimeoutExpired failed the entire job).
|
||||
print(f"[strip-art] WARN: metaflac timed out on {flac_path}, skipping file")
|
||||
return None
|
||||
if r.returncode != 0 or not os.path.exists(tmp):
|
||||
return None
|
||||
sz = os.path.getsize(tmp)
|
||||
|
||||
Reference in New Issue
Block a user