0.6.13: Fix watermark maintenance jobs, honest buy-link write reporting, Qobuz 403 diagnosis

scrub-watermark-text.py crashed on every run (missing import os). strip-watermark-art.py
was starved every Sunday by lock contention from strip-mb-tags' growing runtime -- moved
it to 01:00 so the rest of the weekly chain has room. enrich-buy-url.py's summary line
counted matches found, not successful writes, so a run where every metaflac write failed
(root-owned files) still reported "N tagged". pipeline-status.sh now tells a 403 from
Qobuz (Akamai/CDN block) apart from a 401 (actually expired token) -- re-exporting the
token does nothing for the former. Also fixes the dedup review table's "Caught by" badge
overflowing into the Keep column's format pill.
This commit is contained in:
andrew
2026-07-22 13:09:34 -06:00
parent e07e931c45
commit 068e7e9534
7 changed files with 48 additions and 14 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.12 docker pull git.kretzer.club/andrew/alembic:0.6.13
``` ```
That is the whole install. You do not need to download the source or build anything. The `0.6.12` 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.13` 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.12 image: git.kretzer.club/andrew/alembic:0.6.13
container_name: alembic container_name: alembic
ports: ports:
- "8420:8420" - "8420:8420"
+12 -1
View File
@@ -155,8 +155,19 @@ MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = {
_log_rotation, _log_rotation,
), ),
# ==== Weekly (Sunday) ==== # ==== Weekly (Sunday) ====
# strip_mb_tags runs first here at 01:00 -- not 08:30 like the rest of
# this chain -- because its runtime isn't stable: 10m19s on 2026-07-12,
# 39.6min on 2026-07-19 (MusicBrainz lookup latency scales with library
# size and isn't under our control). At 08:30 that variance repeatedly
# starved every job behind it: strip_watermark_art waited the full
# SCHEDULED_LOCK_WAIT_SECONDS and gave up every week from 07-12 onward,
# and on 07-19 the starvation cascaded all the way through genre:run,
# normalize_casing, beets_update_sync, dedup:scan, and both gen_*_playlist
# jobs. 01:00 sits in the dead zone before the first playlist sync (05:00)
# and well after log_rotation (00:00), so even a run several times slower
# than 07-19's is guaranteed to release the lock long before 08:30.
"maintenance:strip_mb_tags": ( "maintenance:strip_mb_tags": (
dict(minute=30, hour=8, day_of_week="sun"), dict(minute=0, hour=1, day_of_week="sun"),
_lib("maintenance:strip_mb_tags", "strip-mb-tags.sh"), _lib("maintenance:strip_mb_tags", "strip-mb-tags.sh"),
), ),
"maintenance:strip_watermark_art": ( "maintenance:strip_watermark_art": (
+7
View File
@@ -558,6 +558,13 @@ tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.badge-pulse::before { animation: pulse-dot 1.4s ease-in-out infinite; } .badge-pulse::before { animation: pulse-dot 1.4s ease-in-out infinite; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
/* The dedup "Caught by" column is only 10.75rem wide -- "Acoustically
Similar" at the default badge size overflowed past it and rendered on
top of the keep-side format pill. Smaller size/padding/tracking keeps it
inside the column instead of shrinking the column itself, which would
just crowd the Keep/Delete path columns next to it. */
.badge-caughtby { font-size: 0.6rem; padding: 0.24rem 0.55rem; letter-spacing: 0.02em; }
/* ---- stacked status bar (playlist track reconciliation) ---- */ /* ---- stacked status bar (playlist track reconciliation) ---- */
.status-bar { .status-bar {
+5 -5
View File
@@ -23,7 +23,7 @@
{% if mode == 'pending' %} {% if mode == 'pending' %}
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td> <td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td>
{% endif %} {% endif %}
<td><span class="badge {{ 'badge-warning' if c.pass_label == 'Acoustically Similar' else 'badge-info' }}" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td> <td><span class="badge badge-caughtby {{ 'badge-warning' if c.pass_label == 'Acoustically Similar' else 'badge-info' }}" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
<td> <td>
<div class="dedup-side dedup-side-keep"> <div class="dedup-side dedup-side-keep">
<span class="badge {{ c.keep.badge }}">{{ c.keep.ext or '?' }}</span> <span class="badge {{ c.keep.badge }}">{{ c.keep.ext or '?' }}</span>
@@ -84,8 +84,8 @@
<div class="table-wrap scroll"> <div class="table-wrap scroll">
<table class="dedup-table"> <table class="dedup-table">
<colgroup> <colgroup>
<col style="width:2.2rem"><col style="width:9.5rem"><col style="width:38%"> <col style="width:2.2rem"><col style="width:10.75rem"><col style="width:37.5%">
<col style="width:38%"><col style="width:10rem"> <col style="width:37.5%"><col style="width:10rem">
</colgroup> </colgroup>
<thead> <thead>
<tr><th></th><th>Caught by</th><th>Keep</th><th>Delete</th><th>Action</th></tr> <tr><th></th><th>Caught by</th><th>Keep</th><th>Delete</th><th>Action</th></tr>
@@ -114,8 +114,8 @@
<div class="table-wrap scroll"> <div class="table-wrap scroll">
<table class="dedup-table"> <table class="dedup-table">
<colgroup> <colgroup>
<col style="width:9.5rem"><col style="width:40%"> <col style="width:10.75rem"><col style="width:39.25%">
<col style="width:40%"><col style="width:7rem"> <col style="width:39.25%"><col style="width:7rem">
</colgroup> </colgroup>
<thead> <thead>
<tr><th>Caught by</th><th>Keep</th><th>Delete</th><th></th></tr> <tr><th>Caught by</th><th>Keep</th><th>Delete</th><th></th></tr>
+12 -4
View File
@@ -458,7 +458,7 @@ def main():
+ (f" upgrade-from={sorted(upgrade_from)}" if upgrade_from else "")) + (f" upgrade-from={sorted(upgrade_from)}" if upgrade_from else ""))
print(f"[enrich] {len(flacs)} FLAC files in library\n") print(f"[enrich] {len(flacs)} FLAC files in library\n")
looked = found = upgraded = 0 looked = found = upgraded = write_failed = 0
by_source = {s: 0 for s in cascade} by_source = {s: 0 for s in cascade}
touched = [] touched = []
for p in flacs: for p in flacs:
@@ -519,17 +519,25 @@ def main():
if set_flac_tag(sp, BUY_URL_TAG, url): if set_flac_tag(sp, BUY_URL_TAG, url):
touched.append(sp) touched.append(sp)
else: else:
print(" ! metaflac write failed") write_failed += 1
print(" ! metaflac write failed (permissions? disk full?) -- NOT tagged")
else: else:
touched.append(sp) touched.append(sp)
if found % 50 == 0: if found % 50 == 0:
print(f" ...{found} links from {looked} lookups so far", flush=True) print(f" ...{found} links from {looked} lookups so far", flush=True)
print(f"\n[enrich] {looked} lookups, {found} {'tagged' if args.apply else 'would-tag'}" # touched is only appended to on an actual successful write (apply mode)
# or a would-tag match (dry run) -- len(touched) is what really landed on
# disk. `found` counts matches regardless of write outcome, so reporting
# `found` here as "tagged" lied about full success on 2026-07-22, when
# every write in the run failed (root-owned files under explo/) but the
# summary line still read "51 tagged".
print(f"\n[enrich] {looked} lookups, {len(touched)} {'tagged' if args.apply else 'would-tag'}"
f" ({', '.join(f'{s}={by_source[s]}' for s in cascade)})" f" ({', '.join(f'{s}={by_source[s]}' for s in cascade)})"
f" no-match={looked - found}" f" no-match={looked - found}"
+ (f" upgraded={upgraded}" if upgrade_from else "")) + (f" upgraded={upgraded}" if upgrade_from else "")
+ (f" WRITE-FAILED={write_failed}" if write_failed else ""))
if args.apply and touched and az_key and args.azuracast_base: if args.apply and touched and az_key and args.azuracast_base:
print("[enrich] telling AzuraCast to reprocess touched files...") print("[enrich] telling AzuraCast to reprocess touched files...")
+8
View File
@@ -425,6 +425,14 @@ PYEOF
case "$q_code" in case "$q_code" in
200) mark_ok "Qobuz token" "valid (buy-link lookup live)" ;; 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" ;; 401) mark_warn "Qobuz token" "EXPIRED — re-export X-User-Auth-Token to ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token" ;;
# A genuinely expired/bad token gets a JSON 401 from Qobuz's own API.
# 403 instead means the request never reached that code at all -- Qobuz's
# Akamai edge is rejecting the request outright (seen 2026-07-22: the
# exact same request got this "Access Denied"/edgesuite.net block from
# alembic's VPN egress IP, but a clean 401 from a non-VPN IP). Re-
# exporting the token does nothing for that -- it's the egress IP's
# reputation, not the credential. Say so, so it isn't mistaken for 401.
403) mark_warn "Qobuz token" "blocked (HTTP 403, likely Akamai/CDN, not the token) — VPN egress IP may be flagged; re-exporting the token won't fix this" ;;
*) mark_warn "Qobuz token" "check failed (HTTP ${q_code:-none})" ;; *) mark_warn "Qobuz token" "check failed (HTTP ${q_code:-none})" ;;
esac esac
else else
+1 -1
View File
@@ -29,7 +29,7 @@ Usage:
scrub-watermark-text.py # dry run scrub-watermark-text.py # dry run
scrub-watermark-text.py --apply # actually strip scrub-watermark-text.py --apply # actually strip
""" """
import sys, re, argparse import os, sys, re, argparse
from pathlib import Path from pathlib import Path
from mutagen import File as MFile from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError from mutagen.id3 import ID3, ID3NoHeaderError