4 Commits

Author SHA1 Message Date
andrew d756aab7fc 0.6.8: Pin sldl skip index + fix dedup scan blinded by the path rewrite
Two bugs conspired to fill the library with .1.flac duplicates on the
night of 2026-07-15/16:

1. sldl derives its skip-index folder from the input: the Spotify
   playlist display name before 0.6.2, the CSV filename after. The
   switch stranded every playlist's download history in the old
   emoji-named subfolder, so sldl started from an empty index and
   re-downloaded every playlist in full. Fix: pin index-path in
   _template.conf, seed the pinned file from all stranded indexes
   (merge-sldl-indexes.py, called from run-playlist.sh whenever the
   pinned index is missing or older than a stranded one). Recovered
   rows keep the CSV-era identity fields but are marked downloaded, so
   tracks that failed last night yet exist in the library since months
   ago are not fetched again.

2. The safety net that should have flagged the flood, dedup-library.sh,
   has silently reported 0 groups since the 2026-07-08 path rewrite:
   host_path() still assumed /music/... display paths and prepended the
   library dir to already-correct /data/music/Library/... paths, so
   every candidate failed the -f check. Fix: pass real paths through
   unchanged, keep the /music prefix swap only for legacy entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:23:53 -06:00
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
andrew be33664be2 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>
2026-07-15 13:30:58 -06:00
8 changed files with 338 additions and 70 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:
```bash
docker pull git.kretzer.club/andrew/alembic:0.6.4
docker pull git.kretzer.club/andrew/alembic:0.6.8
```
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.8` 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.8
container_name: alembic
ports:
- "8420:8420"
+15
View File
@@ -96,6 +96,21 @@ else
log "Fetched $TRACK_COUNT track(s) from Spotify"
fi
# ==== Seed the pinned sldl skip index if it's missing or stranded ====
# The conf pins index-path to $DROPBOX/_index.csv (see _template.conf: sldl's
# default index location is derived from the input, so an input change strands
# the old index and the whole playlist re-downloads -- that's what produced
# the 2026-07-16 duplicate flood). If the pinned file is missing, or an index
# in one of sldl's old input-named subfolders is newer (i.e. sldl last wrote
# somewhere else), fold them all into the pinned location first.
INDEX_FILE="$DROPBOX/_index.csv"
if [[ ! -s "$INDEX_FILE" ]] || \
[[ -n "$(find "$DROPBOX" -mindepth 2 -maxdepth 2 -name _index.csv -newer "$INDEX_FILE" -print -quit 2>/dev/null)" ]]; then
log "Seeding pinned sldl index at $INDEX_FILE from prior indexes"
python3 "${PIPELINE_DIR:-/app/pipeline}/lib/merge-sldl-indexes.py" "$DROPBOX" "$INDEX_FILE" >> "$LOG" 2>&1 \
|| log "WARNING: index merge failed -- sldl may re-download tracks the library already has"
fi
# ==== Run sldl (vendored binary, subprocess of this container) ====
log "Running sldl for: $PLAYLIST_NAME"
+12
View File
@@ -31,6 +31,18 @@ path = SLDL_DROPBOX_ROOT/PLAYLIST_NAME
playlist-path = SLDL_DROPBOX_ROOT/PLAYLIST_NAME/_sldl.m3u8
write-playlist = true
# ==== Skip index (pinned!) ====
# sldl's already-downloaded index defaults to {path}/{playlist-name}/_index.csv
# where {playlist-name} is derived from the INPUT: the Spotify playlist's
# display name for spotify input, the CSV filename for csv input. beets moves
# every download out of the dropbox, so this index is the ONLY thing standing
# between a nightly run and re-downloading the whole playlist. When 0.6.2
# switched input from Spotify URL to CSV, the derived name changed, sldl
# started a fresh empty index in a new subfolder, and every playlist
# re-downloaded in full on 2026-07-16. Pinning the path here decouples the
# index from input naming so that can never happen again.
index-path = SLDL_DROPBOX_ROOT/PLAYLIST_NAME/_index.csv
# ==== Naming ====
name-format = {artist} - {title}
+22 -7
View File
@@ -78,8 +78,22 @@ fi
# Counters: tracked via log grep at the end (avoids bash subshell pitfalls).
# Functions write KEEP/DELETE lines with consistent prefixes; summary greps them.
# Convert container path (/music/...) to host path (${MUSIC_DATA_DIR:-/data/music}/Library/...)
host_path() { echo "${MUSIC_DATA_DIR:-/data/music}/Library${1#/music}"; }
# Resolve a beets-displayed path to a real path in this container. Since the
# 2026-07-08 path rewrite, beets displays ${MUSIC_DATA_DIR:-/data/music}/Library/...
# paths that are directly usable — pass those through UNCHANGED. Only legacy
# /music/... display paths (the pre-rewrite transitional mount) still need the
# prefix swap. Blindly prepending the library dir to an already-correct path
# (the old behavior) produced /data/music/Library/data/music/Library/... —
# nonexistent, so every candidate failed the -f check and every pass reported
# 0 groups from 2026-07-08 until this fix.
host_path() {
local p="$1"
if [[ "$p" == /music/* ]]; then
echo "${MUSIC_DATA_DIR:-/data/music}/Library${p#/music}"
else
echo "$p"
fi
}
SEP=$'\x1c' # ASCII file separator — safe with any music metadata
@@ -202,9 +216,10 @@ process_group() {
log ""
log "[$(date -Iseconds)] === Pass 1: numbered siblings ==="
# Dump id + container path from beets once. beets normalizes $path for
# display (always /music/-prefixed) even though the DB stores a mix of
# absolute and relative — so the display paths are safe to compare/convert,
# Dump id + display path from beets once. beets normalizes $path for display
# (real /data/music/Library/... paths since the 2026-07-08 rewrite; /music/...
# on any legacy entry) even though the DB stores a mix of absolute and
# relative — so the display paths are safe to compare/convert via host_path(),
# and the ids are what we hand to beet remove/move.
beet ls -f "\$id${SEP}\$path" 2>/dev/null > /tmp/beets-id-paths.txt
cut -d"$SEP" -f2- /tmp/beets-id-paths.txt > /tmp/beets-all-paths.txt
@@ -231,7 +246,7 @@ while IFS="$SEP" read -r numbered_id numbered_cp; do
if [[ $score_canonical -le $score_numbered ]]; then
# Canonical wins: delete numbered (in beets), keep canonical (ghost)
process_group "P1 numbered-sibling: ${numbered_cp##/music/}" \
process_group "P1 numbered-sibling: ${numbered_cp##*/Library/}" \
"$host_canonical" "${numbered_id}${SEP}${numbered_cp}"
if [[ $APPLY -eq 1 ]]; then
# canonical is now a ghost file; import it into beets
@@ -240,7 +255,7 @@ while IFS="$SEP" read -r numbered_id numbered_cp; do
else
# Numbered wins (canonical is ghost): rm the ghost, then beet move to rename
# the numbered file to the canonical path (id query — see process_group).
process_group "P1 numbered-sibling: ${numbered_cp##/music/}" \
process_group "P1 numbered-sibling: ${numbered_cp##*/Library/}" \
"${numbered_id}${SEP}${numbered_cp}" "$host_canonical"
if [[ $APPLY -eq 1 ]]; then
beet move "id:${numbered_id}" >> "$LOG" 2>&1 || log " WARN: beet move id:${numbered_id} failed"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Fold every sldl skip index under a playlist's dropbox into one pinned file.
sldl names its per-playlist index folder after the *input*: the Spotify
playlist's display name for spotify input, the CSV filename stem for csv
input. So when 0.6.2 switched input from Spotify URL to CSV, sldl started a
fresh empty index in a new subfolder, saw no download history, and
re-downloaded every playlist in full (2026-07-16). The rendered confs now pin
index-path to <dropbox>/<playlist>/_index.csv; this script seeds that pinned
file from all the indexes sldl left behind (run-playlist.sh calls it whenever
the pinned file is missing or older than a stranded one).
Usage: merge-sldl-indexes.py <playlist_dropbox_dir> <output_index_csv>
Merge rules, per (artist, title) lowercased:
- the row from the newest index file wins;
- EXCEPT when that row is a failure (state 2) and any older index has the
track as downloaded (state 1 or 3): then the newest row's identity
(artist/album/title/length -- matching what the current input will
present; Spotify length rounding drifted between the old extractor and
our CSV) is kept but marked state 3 (already downloaded), so sldl does
not re-fetch a track the library already holds;
- rows only present in older indexes are kept as-is (tracks since removed
from the playlist; harmless, and they keep their history if re-added).
"""
import csv
import sys
from pathlib import Path
HEADER = ["filepath", "artist", "album", "title", "length", "tracktype", "state", "failurereason"]
DOWNLOADED_STATES = {"1", "3"} # 1 = downloaded this run, 3 = found in index previously
FAILED_STATE = "2"
def read_rows(path: Path) -> list[dict]:
rows = []
with open(path, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
for row in reader:
if row.get("artist") is None or row.get("title") is None:
continue
rows.append({k: (row.get(k) or "") for k in HEADER})
return rows
def norm_key(row: dict) -> tuple:
# Primary artist only: sldl's old Spotify extractor recorded just the
# first artist, while spotify-playlist-csv.py joins all of them with
# ", " -- keying on the full string would miss every multi-artist track
# when recovering history across the two index generations. First
# comma-segment matches both forms (and both sides of a comma-in-name
# artist like "Tyler, The Creator" truncate identically).
return (row["artist"].split(",")[0].strip().lower(), row["title"].strip().lower())
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
dropbox = Path(sys.argv[1])
out_path = Path(sys.argv[2])
if not dropbox.is_dir():
print(f"[merge-sldl-indexes] not a directory: {dropbox}", file=sys.stderr)
return 2
# Every index at the dropbox root or one level down (sldl's input-named
# subfolders), including the pinned output itself if it already exists --
# newest first, so the most recent record of each track wins.
candidates = sorted(
set(dropbox.glob("_index.csv")) | set(dropbox.glob("*/_index.csv")),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not candidates:
print(f"[merge-sldl-indexes] no _index.csv found under {dropbox}; nothing to seed")
return 0
merged: dict[tuple, dict] = {}
recovered = 0
for path in candidates:
try:
rows = read_rows(path)
except (OSError, csv.Error) as exc:
print(f"[merge-sldl-indexes] skipping unreadable {path}: {exc}", file=sys.stderr)
continue
print(f"[merge-sldl-indexes] {path}: {len(rows)} rows")
for row in rows:
key = norm_key(row)
kept = merged.get(key)
if kept is None:
merged[key] = row
elif kept["state"] == FAILED_STATE and row["state"] in DOWNLOADED_STATES:
# Newest attempt failed but an older index proves we already
# have this track: keep the newest identity fields, take the
# old filepath (informational only; skip-mode index never
# checks the file on disk), and mark it downloaded.
kept["filepath"] = row["filepath"]
kept["state"] = "3"
kept["failurereason"] = "0"
recovered += 1
out_path.parent.mkdir(parents=True, exist_ok=True)
tmp = out_path.with_suffix(".csv.tmp")
with open(tmp, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=HEADER)
writer.writeheader()
writer.writerows(merged.values())
tmp.replace(out_path)
downloaded = sum(1 for r in merged.values() if r["state"] in DOWNLOADED_STATES)
print(
f"[merge-sldl-indexes] wrote {out_path}: {len(merged)} tracks "
f"({downloaded} downloaded, {recovered} recovered from older indexes)"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+37 -9
View File
@@ -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)
+117 -47
View File
@@ -4,9 +4,24 @@
# 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 uses Telegram HTML entities (<b>, <i>, <code>,
# <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). Deliberately NO <pre>/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 —
# alembic has no Docker socket access
# - Sibling service reachability (navidrome) via HTTP, not docker ps —
# 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
# - Weekly maintenance freshness: strip-mb-tags, strip-watermark-art,
# scrub-watermark-text, clean-sldl-index, spotify-genre
@@ -27,7 +42,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)
@@ -44,12 +62,39 @@ OK=0
WARN=0
SKIP=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//&/&amp;}
s=${s//</&lt;}
s=${s//>/&gt;}
printf '%s' "$s"
}
section() { printf "\n▎ %s\n" "$*"; }
# 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 <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 "$*")"; }
# Bold section header (reads like the web app's <h2>). No <pre> — the rows
# under it are plain reflowing text.
section() {
printf "\n<b>▎ %s</b>\n" "$(esc "$*")"
}
# 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
@@ -80,7 +125,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
@@ -140,12 +185,11 @@ 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 navidrome "http://navidrome:4533/rest/ping.view"
# 2. Today's runs
@@ -186,14 +230,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)"
@@ -205,13 +247,16 @@ 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 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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'
fi
# 3. Maintenance freshness
@@ -284,7 +329,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
@@ -294,7 +339,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")
@@ -332,11 +377,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
@@ -348,13 +394,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
@@ -374,12 +423,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
@@ -389,17 +438,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
@@ -424,24 +476,40 @@ 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}'
| awk '{printf " %s — %d tracks\n", $2, $1}' \
| sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'
} > "$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 +518,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
+11 -4
View File
@@ -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)