Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55a059b6da | |||
| 068e7e9534 |
@@ -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.14
|
||||||
```
|
```
|
||||||
|
|
||||||
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.14` 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.14
|
||||||
container_name: alembic
|
container_name: alembic
|
||||||
ports:
|
ports:
|
||||||
- "8420:8420"
|
- "8420:8420"
|
||||||
|
|||||||
@@ -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": (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -242,6 +242,30 @@ BEETS_EXIT=0
|
|||||||
beet import -q -s "$DEST_DIR" >> "$LOG" 2>&1 || BEETS_EXIT=$?
|
beet import -q -s "$DEST_DIR" >> "$LOG" 2>&1 || BEETS_EXIT=$?
|
||||||
log "beets import finished with exit code $BEETS_EXIT"
|
log "beets import finished with exit code $BEETS_EXIT"
|
||||||
|
|
||||||
|
# ==== Safety net: clean up stragglers + exact duplicates ====
|
||||||
|
# With duplicate_action=keep, beets always imports rather than rejecting a
|
||||||
|
# real conflict (see beets/config.yaml) -- any file whose (albumartist,
|
||||||
|
# album, title) already exists in the library gets imported anyway as a
|
||||||
|
# `.1.ext` sibling. That's true whether this run came from the web UI, an
|
||||||
|
# SMB drop, or sync-bandcamp.sh, so the cleanup has to run here rather than
|
||||||
|
# per-caller (a 2026-07-23 incident: a Bandcamp sync's own call into this
|
||||||
|
# script failed and silently stranded ~150 already-owned files in import-me/
|
||||||
|
# for two weeks; a later unrelated manual import swept them back in and
|
||||||
|
# duplicated them, and nothing had deduped since).
|
||||||
|
log "Running replace-with-better safety pass on /downloads stragglers"
|
||||||
|
if ${PIPELINE_DIR:-/app/pipeline}/lib/replace-with-better.sh --apply >> "$LOG" 2>&1; then
|
||||||
|
log "replace-with-better OK"
|
||||||
|
else
|
||||||
|
log "WARN: replace-with-better.sh exited non-zero (exit $?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Running dedup pass (exact-match duplicates only; FLAC > MP3, then largest file)"
|
||||||
|
if ${PIPELINE_DIR:-/app/pipeline}/lib/dedup-library.sh --apply >> "$LOG" 2>&1; then
|
||||||
|
log "dedup OK"
|
||||||
|
else
|
||||||
|
log "WARN: dedup-library.sh exited non-zero (exit $?)"
|
||||||
|
fi
|
||||||
|
|
||||||
# ==== Regenerate M3U if playlist was specified ====
|
# ==== Regenerate M3U if playlist was specified ====
|
||||||
if [[ -n "$PLAYLIST_NAME" ]]; then
|
if [[ -n "$PLAYLIST_NAME" ]]; then
|
||||||
log "Regenerating M3U for $PLAYLIST_NAME"
|
log "Regenerating M3U for $PLAYLIST_NAME"
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
# Returns exit 0 on send-OK, non-zero otherwise.
|
# Returns exit 0 on send-OK, non-zero otherwise.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
# /opt/venv/bin leads PATH for consistency with the other pipeline scripts,
|
||||||
|
# even though this one only shells out to curl.
|
||||||
|
PATH=/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
PARSE_MODE=""
|
PARSE_MODE=""
|
||||||
if [[ "${1:-}" == "--html" ]]; then
|
if [[ "${1:-}" == "--html" ]]; then
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,15 @@
|
|||||||
# Run as root (cron). Logs to ${ALEMBIC_CONFIG_DIR:-/config}/logs/bandcamp-YYYYMMDD.log.
|
# Run as root (cron). Logs to ${ALEMBIC_CONFIG_DIR:-/config}/logs/bandcamp-YYYYMMDD.log.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
# /opt/venv/bin must lead PATH -- that's where `beet` lives (the app's own
|
||||||
|
# subprocess env puts it there too, see pipeline_runner._subprocess_env()).
|
||||||
|
# This script used to run as a bare root cron job (pre-2026-07-08 cutover to
|
||||||
|
# the app scheduler) where a minimal hardened PATH made sense; omitting the
|
||||||
|
# venv here silently broke every downstream `beet import` call inside
|
||||||
|
# import-track.sh once this started running through the app instead --
|
||||||
|
# import-track.sh swallows that failure and still reports OK, so a purchase
|
||||||
|
# would sit unimported until someone noticed it missing from Navidrome.
|
||||||
|
PATH=/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
export PATH
|
export PATH
|
||||||
|
|
||||||
CONFIG="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/config.env"
|
CONFIG="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/bandcamp/config.env"
|
||||||
@@ -87,7 +95,10 @@ shopt -u dotglob nullglob
|
|||||||
|
|
||||||
# Hand off to the standard manual-import pipeline. No playlist tag — Bandcamp
|
# Hand off to the standard manual-import pipeline. No playlist tag — Bandcamp
|
||||||
# purchases aren't part of any Spotify playlist. The import-track.sh guard,
|
# purchases aren't part of any Spotify playlist. The import-track.sh guard,
|
||||||
# albumartist fallback, beets import, and Navidrome scan all kick in normally.
|
# albumartist fallback, beets import, straggler/dedup safety net, and
|
||||||
|
# Navidrome scan all kick in normally (import-track.sh runs the
|
||||||
|
# replace-with-better + dedup-library safety net itself now, for every
|
||||||
|
# caller, not just this one).
|
||||||
log "Calling import-track.sh"
|
log "Calling import-track.sh"
|
||||||
if ${PIPELINE_DIR:-/app/pipeline}/bin/import-track.sh >> "$LOG" 2>&1; then
|
if ${PIPELINE_DIR:-/app/pipeline}/bin/import-track.sh >> "$LOG" 2>&1; then
|
||||||
log "import-track.sh OK"
|
log "import-track.sh OK"
|
||||||
@@ -95,28 +106,5 @@ else
|
|||||||
log "WARN: import-track.sh exited non-zero (exit $?)"
|
log "WARN: import-track.sh exited non-zero (exit $?)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Safety net: if anything is stranded in ${MUSIC_DATA_DIR:-/data/music}/downloads (rare with
|
|
||||||
# duplicate_action=keep set in beets config, but possible for files beets
|
|
||||||
# couldn't process), find each one's library counterpart and replace if the
|
|
||||||
# new file is higher quality, OR import as new if there's no counterpart.
|
|
||||||
log "Running replace-with-better safety pass on /downloads stragglers"
|
|
||||||
if ${PIPELINE_DIR:-/app/pipeline}/lib/replace-with-better.sh --apply >> "$LOG" 2>&1; then
|
|
||||||
log "replace-with-better OK"
|
|
||||||
else
|
|
||||||
log "WARN: replace-with-better.sh exited non-zero (exit $?)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Now dedupe across the library. With duplicate_action=keep beets imported
|
|
||||||
# every Bandcamp file even when it conflicts with an existing track at the
|
|
||||||
# same path (it creates `.1.ext` siblings). dedup-library.sh's policy is
|
|
||||||
# "FLAC > MP3, then largest file" — Bandcamp version wins, soulseek version
|
|
||||||
# is removed from both the beets DB and disk.
|
|
||||||
log "Running dedup pass (Bandcamp FLAC wins over older Soulseek copies)"
|
|
||||||
if ${PIPELINE_DIR:-/app/pipeline}/lib/dedup-library.sh --apply >> "$LOG" 2>&1; then
|
|
||||||
log "dedup OK"
|
|
||||||
else
|
|
||||||
log "WARN: dedup-library.sh exited non-zero (exit $?)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "=== Bandcamp sync done ==="
|
log "=== Bandcamp sync done ==="
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
# upgrade-mp3-to-flac.sh --csv-only # just write the CSV; don't run sldl
|
# upgrade-mp3-to-flac.sh --csv-only # just write the CSV; don't run sldl
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
# /opt/venv/bin must lead PATH -- `beet` (used heavily below) lives there.
|
||||||
|
PATH=/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
export PATH
|
export PATH
|
||||||
|
|
||||||
# These helpers are the same ones import-track.sh sources and runs under its
|
# These helpers are the same ones import-track.sh sources and runs under its
|
||||||
|
|||||||
Reference in New Issue
Block a user