Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24738d815f | |||
| 900bbff8aa | |||
| be33664be2 | |||
| 7a49e2d507 |
@@ -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.3
|
||||
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.3` 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.)
|
||||
|
||||
@@ -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.3
|
||||
image: git.kretzer.club/andrew/alembic:0.6.7
|
||||
container_name: alembic
|
||||
ports:
|
||||
- "8420:8420"
|
||||
|
||||
@@ -12,6 +12,15 @@ from app.settings import settings
|
||||
# all-jobs-share-one-lock behavior exactly rather than over-engineering it.
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
# How long a SCHEDULED run waits for the lock before giving up (recorded as
|
||||
# skipped_lock). The old flock -n instant-skip starves fixed-time schedules:
|
||||
# the Sunday 08:30 strip-mb-tags run took 10m19s on 2026-07-12, so the 08:35
|
||||
# and 08:40 jobs both hit the held lock and silently lost their only slot of
|
||||
# the week. Scheduled jobs have nobody watching, so queueing (bounded) beats
|
||||
# skipping; manual runs keep the instant skip because a person clicking "Run
|
||||
# now" should get an immediate answer, not a silent 30-minute wait.
|
||||
SCHEDULED_LOCK_WAIT_SECONDS = 1800.0
|
||||
|
||||
_ENV_PASSTHROUGH_KEYS = ("PATH", "HOME", "LANG", "LC_ALL", "TZ")
|
||||
|
||||
|
||||
@@ -94,18 +103,30 @@ async def _execute(
|
||||
timeout: float | None,
|
||||
use_lock: bool,
|
||||
capture: bool,
|
||||
lock_wait: float | None,
|
||||
) -> tuple[JobRun, str]:
|
||||
"""Shared core for run_job/run_job_capture: acquire the lock (unless
|
||||
use_lock=False), record the run, exec the subprocess, and finalize. When
|
||||
capture=True, stdout+stderr is captured as text and returned; otherwise it
|
||||
streams straight to the log file. Returns (JobRun, output_text) -- the text
|
||||
is "" in the non-capture and skipped-lock cases."""
|
||||
is "" in the non-capture and skipped-lock cases.
|
||||
|
||||
lock_wait: how long to wait for a held lock before recording
|
||||
skipped_lock. None picks the policy default: SCHEDULED_LOCK_WAIT_SECONDS
|
||||
for triggered_by="schedule", instant skip for everything else."""
|
||||
started_at = time.time()
|
||||
if lock_wait is None:
|
||||
lock_wait = SCHEDULED_LOCK_WAIT_SECONDS if triggered_by == "schedule" else 0.0
|
||||
|
||||
acquired = False
|
||||
if use_lock:
|
||||
if not await _try_acquire_nowait():
|
||||
return _record_run(job_key, started_at, triggered_by, finished_at=started_at, status="skipped_lock"), ""
|
||||
if lock_wait <= 0:
|
||||
return _record_run(job_key, started_at, triggered_by, finished_at=started_at, status="skipped_lock"), ""
|
||||
try:
|
||||
await asyncio.wait_for(_lock.acquire(), timeout=lock_wait)
|
||||
except asyncio.TimeoutError:
|
||||
return _record_run(job_key, started_at, triggered_by, finished_at=time.time(), status="skipped_lock"), ""
|
||||
acquired = True
|
||||
|
||||
try:
|
||||
@@ -170,18 +191,21 @@ async def run_job(
|
||||
triggered_by: str = "schedule",
|
||||
timeout: float | None = None,
|
||||
use_lock: bool = True,
|
||||
lock_wait: float | None = None,
|
||||
) -> JobRun:
|
||||
"""Run a pipeline command under the shared mutual-exclusion lock,
|
||||
recording one job_runs row start-to-finish. If the lock is already
|
||||
held, records status='skipped_lock' immediately and returns without
|
||||
running anything -- the old flock -n behavior, now visible in the UI
|
||||
instead of silently skipping.
|
||||
recording one job_runs row start-to-finish. If the lock is already held,
|
||||
scheduled runs (triggered_by="schedule") wait up to
|
||||
SCHEDULED_LOCK_WAIT_SECONDS for it before recording skipped_lock -- see
|
||||
that constant for why -- while manual/other runs record skipped_lock
|
||||
immediately (the old flock -n behavior, visible in the UI instead of
|
||||
silently skipping). Pass lock_wait to override either way.
|
||||
|
||||
use_lock=False runs the command WITHOUT taking the pipeline lock, for
|
||||
read-only jobs that never touch the library (e.g. the status report).
|
||||
Those must never be starved by a long-running write job, and running
|
||||
them concurrently is safe."""
|
||||
run, _ = await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=False)
|
||||
run, _ = await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=False, lock_wait=lock_wait)
|
||||
return run
|
||||
|
||||
|
||||
@@ -191,6 +215,7 @@ async def run_job_capture(
|
||||
triggered_by: str = "manual",
|
||||
timeout: float | None = None,
|
||||
use_lock: bool = True,
|
||||
lock_wait: float | None = None,
|
||||
) -> tuple[JobRun, str]:
|
||||
"""Like run_job(), but captures stdout+stderr as text and returns it
|
||||
alongside the JobRun, instead of only writing it to the log file --
|
||||
@@ -198,7 +223,7 @@ async def run_job_capture(
|
||||
dedup_review_service's dry-run scan. The full output is still written
|
||||
to a log file afterward so job_runs.log_path works the same as any
|
||||
other job."""
|
||||
return await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=True)
|
||||
return await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=True, lock_wait=lock_wait)
|
||||
|
||||
|
||||
async def run_playlist(playlist_name: str, no_m3u: bool = False, triggered_by: str = "schedule") -> JobRun:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+215
-97
@@ -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//&/&}
|
||||
s=${s//</<}
|
||||
s=${s//>/>}
|
||||
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
|
||||
@@ -70,17 +115,6 @@ human_age() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Newest matching log → "<age_seconds>|<path>", empty if no match.
|
||||
newest_log() {
|
||||
local glob="$1"
|
||||
local newest
|
||||
newest=$(ls -1t $glob 2>/dev/null | head -1)
|
||||
[[ -z "$newest" ]] && return
|
||||
local mtime
|
||||
mtime=$(stat -c %Y "$newest" 2>/dev/null) || return
|
||||
echo "$((NOW_TS - mtime))|$newest"
|
||||
}
|
||||
|
||||
# Per-playlist log status.
|
||||
playlist_status() {
|
||||
local log="$1"
|
||||
@@ -91,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
|
||||
@@ -151,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
|
||||
@@ -197,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)"
|
||||
|
||||
@@ -216,30 +247,28 @@ 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/&/\&/g; s/</\</g; s/>/\>/g'
|
||||
fi
|
||||
|
||||
# 3. Maintenance freshness
|
||||
# Each weekly/monthly task: find its newest log and check age.
|
||||
# Read from the app's job_runs table (the scheduler's own record) instead of
|
||||
# globbing for log files. Jobs run through pipeline_runner write their logs
|
||||
# to logs/<job_key>/<timestamp>.log, so the old cron-era filename globs
|
||||
# matched nothing for jobs that don't also write their own log, and healthy
|
||||
# jobs were reported as "never run yet". The DB also distinguishes lock
|
||||
# skips and failures, which a missing log file can't.
|
||||
# Limits: weekly tasks should be <9 days old; daily <2; monthly <35.
|
||||
section "Maintenance (last run)"
|
||||
|
||||
declare -A MAINT_GLOB=(
|
||||
[strip-mb-tags]="${ALEMBIC_CONFIG_DIR:-/config}/logs/mb-strip-*.log"
|
||||
[strip-watermark-art]="${ALEMBIC_CONFIG_DIR:-/config}/logs/strip-watermark-*.log"
|
||||
[scrub-watermark-text]="${ALEMBIC_CONFIG_DIR:-/config}/logs/scrub-text-*.log"
|
||||
[clean-sldl-index]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clean-index-*.log"
|
||||
[clear-bad-genres]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clear-bad-genres-*.log"
|
||||
[spotify-genre]="${ALEMBIC_CONFIG_DIR:-/config}/logs/spotify-genre-*.log"
|
||||
[dedup-library]="${ALEMBIC_CONFIG_DIR:-/config}/logs/dedup-*.log"
|
||||
[upgrade-mp3-to-flac]="${ALEMBIC_CONFIG_DIR:-/config}/logs/upgrade-mp3-*.log"
|
||||
)
|
||||
declare -A MAINT_LIMIT=(
|
||||
[strip-mb-tags]=$((9*86400))
|
||||
[strip-watermark-art]=$((9*86400))
|
||||
@@ -250,45 +279,110 @@ dedup_today_status() {
|
||||
[dedup-library]=$((2*86400))
|
||||
[upgrade-mp3-to-flac]=$((35*86400))
|
||||
)
|
||||
# Emits one line per task: task|latest_status|latest_age_s|success_age_s|success_log
|
||||
# (ages are -1 when there is no such run). Read-only DB open; prints nothing
|
||||
# if the DB is missing so every task falls through to "never run yet".
|
||||
maint_rows=$(python3 - "${ALEMBIC_CONFIG_DIR:-/config}/alembic.db" <<'PYEOF'
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Digest label -> job_runs.job_key. The scheduled genre refresh records under
|
||||
# genre:run (via genre_review_service) and dedup under dedup:scan (via
|
||||
# dedup_review_service); everything else is its MAINTENANCE_JOBS key.
|
||||
KEYS = [
|
||||
("strip-mb-tags", "maintenance:strip_mb_tags"),
|
||||
("strip-watermark-art", "maintenance:strip_watermark_art"),
|
||||
("scrub-watermark-text", "maintenance:scrub_watermark_text"),
|
||||
("clean-sldl-index", "maintenance:clean_sldl_index"),
|
||||
("clear-bad-genres", "maintenance:clear_bad_genres"),
|
||||
("spotify-genre", "genre:run"),
|
||||
("dedup-library", "dedup:scan"),
|
||||
("upgrade-mp3-to-flac", "maintenance:upgrade_mp3_to_flac"),
|
||||
]
|
||||
now = time.time()
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
|
||||
conn.execute("SELECT 1 FROM job_runs LIMIT 1")
|
||||
except sqlite3.Error:
|
||||
sys.exit(0)
|
||||
for task, key in KEYS:
|
||||
latest = conn.execute(
|
||||
"SELECT status, started_at FROM job_runs WHERE job_key = ? ORDER BY started_at DESC LIMIT 1",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if latest is None:
|
||||
print(f"{task}|none|-1|-1|")
|
||||
continue
|
||||
success = conn.execute(
|
||||
"SELECT started_at, log_path FROM job_runs WHERE job_key = ? AND status = 'success' "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(key,),
|
||||
).fetchone()
|
||||
s_age = int(now - success[0]) if success else -1
|
||||
s_log = (success[1] or "") if success else ""
|
||||
print(f"{task}|{latest[0]}|{int(now - latest[1])}|{s_age}|{s_log}")
|
||||
PYEOF
|
||||
)
|
||||
|
||||
for task in strip-mb-tags strip-watermark-art scrub-watermark-text clean-sldl-index clear-bad-genres spotify-genre dedup-library upgrade-mp3-to-flac; do
|
||||
res=$(newest_log "${MAINT_GLOB[$task]}")
|
||||
if [[ -z "$res" ]]; then
|
||||
mark_skip "$(printf '%-22s never run yet' "$task")"
|
||||
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 "$task" "never run yet"
|
||||
continue
|
||||
fi
|
||||
if [[ "$age_s" -lt 0 ]]; then
|
||||
# attempted, but never once succeeded -- say what the last attempt did
|
||||
case "$latest_status" in
|
||||
skipped_lock) note="skipped, lock busy" ;;
|
||||
running) note="running now" ;;
|
||||
*) note="$latest_status" ;;
|
||||
esac
|
||||
mark_warn "$task" "never succeeded (last attempt $(human_age "$latest_age"): $note)"
|
||||
continue
|
||||
fi
|
||||
age_s=${res%%|*}
|
||||
log=${res##*|}
|
||||
age_h=$(human_age "$age_s")
|
||||
case "$task" in
|
||||
spotify-genre)
|
||||
snip=$(grep -oE "written: [0-9]+, unchanged: [0-9]+" "$log" | tail -1) ;;
|
||||
clear-bad-genres)
|
||||
snip=$(grep -oE "(would-clear|cleared): [0-9]+ tracks" "$log" | tail -1) ;;
|
||||
clean-sldl-index)
|
||||
snip=$(grep -oE "[0-9]+ m3us, total [0-9]+ kept, [0-9]+ dropped" "$log" | tail -1) ;;
|
||||
strip-watermark-art)
|
||||
snip=$(grep -oE "stripped from [0-9]+ tracks|no images shared" "$log" | tail -1) ;;
|
||||
scrub-watermark-text)
|
||||
snip=$(grep -oE "stripped [0-9]+ frame\(s\) across [0-9]+ file" "$log" | tail -1) ;;
|
||||
strip-mb-tags)
|
||||
snip=$(grep -oE "Done\. Log:" "$log" | head -1 | sed 's/Done\. Log:/done/') ;;
|
||||
dedup-library)
|
||||
snip=$(grep "=== Summary:" "$log" | tail -1 \
|
||||
| sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//') ;;
|
||||
upgrade-mp3-to-flac)
|
||||
# The log's "to attempt" count is frozen at the last monthly run, so on
|
||||
# its own it looks stale against the live "Library by format" section.
|
||||
# Pair it with the current format:mp3 count so the drop (upgrades that
|
||||
# have landed since) is visible instead of looking like a mismatch.
|
||||
attempted=$(grep -oE "[0-9]+ MP3 tracks to attempt" "$log" | grep -oE "^[0-9]+" | tail -1)
|
||||
mp3_now=$(beet ls 'format:mp3' 2>/dev/null | grep -c '^')
|
||||
snip="${attempted:-?} attempted · ${mp3_now} MP3 now" ;;
|
||||
snip=""
|
||||
if [[ -n "$log" && -f "$log" ]]; then
|
||||
case "$task" in
|
||||
spotify-genre)
|
||||
snip=$(grep -oE "written: [0-9]+, unchanged: [0-9]+" "$log" | tail -1) ;;
|
||||
clear-bad-genres)
|
||||
snip=$(grep -oE "rewrote: [0-9]+ tracks, blanked: [0-9]+ tracks|(would-clear|cleared): [0-9]+ tracks" "$log" | tail -1) ;;
|
||||
clean-sldl-index)
|
||||
snip=$(grep -oE "[0-9]+ m3us, total [0-9]+ kept, [0-9]+ dropped" "$log" | tail -1) ;;
|
||||
strip-watermark-art)
|
||||
snip=$(grep -oE "stripped from [0-9]+ tracks|no images shared" "$log" | tail -1) ;;
|
||||
scrub-watermark-text)
|
||||
snip=$(grep -oE "stripped [0-9]+ frame\(s\) across [0-9]+ file" "$log" | tail -1) ;;
|
||||
strip-mb-tags)
|
||||
snip=$(grep -oE "Done\. Log:" "$log" | head -1 | sed 's/Done\. Log:/done/') ;;
|
||||
dedup-library)
|
||||
snip=$(grep "=== Summary:" "$log" | tail -1 \
|
||||
| sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//') ;;
|
||||
upgrade-mp3-to-flac)
|
||||
# The log's "to attempt" count is frozen at the last monthly run, so on
|
||||
# its own it looks stale against the live "Library by format" section.
|
||||
# Pair it with the current format:mp3 count so the drop (upgrades that
|
||||
# have landed since) is visible instead of looking like a mismatch.
|
||||
attempted=$(grep -oE "[0-9]+ MP3 tracks to attempt" "$log" | grep -oE "^[0-9]+" | tail -1)
|
||||
mp3_now=$(beet ls 'format:mp3' 2>/dev/null | grep -c '^')
|
||||
snip="${attempted:-?} attempted · ${mp3_now} MP3 now" ;;
|
||||
esac
|
||||
fi
|
||||
# Age/snippet describe the last SUCCESS; if a newer attempt failed or was
|
||||
# lock-skipped, say so rather than hiding it behind the healthy line.
|
||||
case "$latest_status" in
|
||||
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:-}")
|
||||
if (( age_s > MAINT_LIMIT[$task] )); then
|
||||
mark_warn "$line"
|
||||
detail="$age_h"
|
||||
[[ -n "$snip" ]] && detail="$age_h · $snip"
|
||||
if (( age_s > MAINT_LIMIT[$task] )) || [[ "$latest_status" == "failed" ]]; then
|
||||
mark_warn "$task" "$detail"
|
||||
else
|
||||
mark_ok "$line"
|
||||
mark_ok "$task" "$detail"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -300,13 +394,16 @@ dedup_today_status() {
|
||||
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
|
||||
@@ -326,12 +423,12 @@ dedup_today_status() {
|
||||
-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
|
||||
@@ -341,17 +438,20 @@ dedup_today_status() {
|
||||
# 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
|
||||
@@ -376,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/&/\&/g; s/</\</g; s/>/\>/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.
|
||||
@@ -402,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,11 +25,41 @@ def test_run_job_failure():
|
||||
assert run.exit_code == 1
|
||||
|
||||
|
||||
def test_lock_skip_when_held():
|
||||
def test_manual_run_skips_immediately_when_lock_held():
|
||||
async def scenario():
|
||||
await pr._lock.acquire()
|
||||
try:
|
||||
return await pr.run_job("test:locked", ["true"], use_lock=True)
|
||||
return await pr.run_job("test:locked", ["true"], use_lock=True, triggered_by="manual")
|
||||
finally:
|
||||
pr._lock.release()
|
||||
|
||||
run = _run(scenario())
|
||||
assert run.status == "skipped_lock"
|
||||
|
||||
|
||||
def test_scheduled_run_waits_for_lock_by_default(monkeypatch):
|
||||
# scheduled runs must QUEUE behind a held lock (bounded), not skip --
|
||||
# instant-skip starved the Sunday maintenance block (see
|
||||
# SCHEDULED_LOCK_WAIT_SECONDS)
|
||||
monkeypatch.setattr(pr, "SCHEDULED_LOCK_WAIT_SECONDS", 5.0)
|
||||
|
||||
async def scenario():
|
||||
await pr._lock.acquire()
|
||||
task = asyncio.ensure_future(pr.run_job("test:queued", ["true"], triggered_by="schedule"))
|
||||
await asyncio.sleep(0.2) # let the job start waiting on the lock
|
||||
pr._lock.release()
|
||||
return await task
|
||||
|
||||
run = _run(scenario())
|
||||
assert run.status == "success"
|
||||
assert not pr._lock.locked()
|
||||
|
||||
|
||||
def test_scheduled_run_skips_after_lock_wait_timeout():
|
||||
async def scenario():
|
||||
await pr._lock.acquire()
|
||||
try:
|
||||
return await pr.run_job("test:waited_out", ["true"], triggered_by="schedule", lock_wait=0.2)
|
||||
finally:
|
||||
pr._lock.release()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user