diff --git a/README.md b/README.md index d24409a..6cdf1e7 100644 --- a/README.md +++ b/README.md @@ -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.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.) @@ -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.12 + image: git.kretzer.club/andrew/alembic:0.6.13 container_name: alembic ports: - "8420:8420" diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index c019e83..6b3725a 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -155,8 +155,19 @@ MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = { _log_rotation, ), # ==== 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": ( - 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"), ), "maintenance:strip_watermark_art": ( diff --git a/app/static/style.css b/app/static/style.css index 83d7899..e560276 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; } @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) ---- */ .status-bar { diff --git a/app/templates/dedup/index.html b/app/templates/dedup/index.html index 7da126a..a8c9b10 100644 --- a/app/templates/dedup/index.html +++ b/app/templates/dedup/index.html @@ -23,7 +23,7 @@ {% if mode == 'pending' %} {% endif %} - {{ c.pass_label }} + {{ c.pass_label }}
{{ c.keep.ext or '?' }} @@ -84,8 +84,8 @@
- - + + @@ -114,8 +114,8 @@
Caught byKeepDeleteAction
- - + + diff --git a/pipeline/lib/enrich-buy-url.py b/pipeline/lib/enrich-buy-url.py index 7d0f856..749acf5 100755 --- a/pipeline/lib/enrich-buy-url.py +++ b/pipeline/lib/enrich-buy-url.py @@ -458,7 +458,7 @@ def main(): + (f" upgrade-from={sorted(upgrade_from)}" if upgrade_from else "")) 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} touched = [] for p in flacs: @@ -519,17 +519,25 @@ def main(): if set_flac_tag(sp, BUY_URL_TAG, url): touched.append(sp) else: - print(" ! metaflac write failed") + write_failed += 1 + print(" ! metaflac write failed (permissions? disk full?) -- NOT tagged") else: touched.append(sp) if found % 50 == 0: 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" 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: print("[enrich] telling AzuraCast to reprocess touched files...") diff --git a/pipeline/lib/pipeline-status.sh b/pipeline/lib/pipeline-status.sh index c5d4ee4..0eab2f6 100755 --- a/pipeline/lib/pipeline-status.sh +++ b/pipeline/lib/pipeline-status.sh @@ -425,6 +425,14 @@ PYEOF case "$q_code" in 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" ;; + # 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})" ;; esac else diff --git a/pipeline/lib/scrub-watermark-text.py b/pipeline/lib/scrub-watermark-text.py index 192da19..22f1235 100755 --- a/pipeline/lib/scrub-watermark-text.py +++ b/pipeline/lib/scrub-watermark-text.py @@ -29,7 +29,7 @@ Usage: scrub-watermark-text.py # dry run scrub-watermark-text.py --apply # actually strip """ -import sys, re, argparse +import os, sys, re, argparse from pathlib import Path from mutagen import File as MFile from mutagen.id3 import ID3, ID3NoHeaderError
Caught byKeepDelete