4 Commits

Author SHA1 Message Date
andrew 1c7cf83a1b 0.6.15: Fix false-positive quarantine of ID3v2.4-tagged MP3s
tag-guard.sh checked MP3 tags with the id3v2 CLI (id3lib-3.8.3, last released
in 2013), which cannot parse ID3v2.4 tags at all. It reported "No ID3 tag" on
files that were completely and correctly tagged, just written in the modern
tag version most taggers default to now (Mp3tag, yt-dlp, foobar2000). Every
manual import of ID3v2.4 MP3s got its files quarantined as untagged and the
whole import failed once nothing survived the quarantine pass.

Replaced the id3v2-based reads in tag-guard.sh with mutagen, which handles
both tag versions correctly. Also replaced the ALBUMARTIST-fallback write
(previously done via `id3v2 --TPE2`) with a mutagen-based write, because
testing turned up something worse: writing a single frame with the id3v2 CLI
to a file that already has an ID3v2.4 tag silently destroys every other
frame on the file (title, album, genre, date, artwork, all of it), since
id3lib doesn't understand v2.4 and rewrites the whole tag as v2.3 containing
only the frame it was told to set. The mutagen write sets just the one frame
and leaves everything else untouched.

Other id3v2 CLI writes still exist elsewhere in the pipeline (playlist
grouping tags, genre fixes, buy-url tagging) and carry the same corruption
risk against ID3v2.4 files; not touched in this release.
2026-07-23 15:40:04 -06:00
andrew 55a059b6da 0.6.14: Fix Bandcamp sync silently failing to import new purchases
sync-bandcamp.sh set a hardcoded PATH at startup that left out /opt/venv/bin,
which is where beet lives in this image. Every time the Bandcamp sync had a
new purchase to import, its call into import-track.sh would run beet import
with that broken PATH, fail with "command not found", and import-track.sh
would just log the exit code and carry on, so sync-bandcamp.sh still reported
success. The purchase sat on disk, never entered the beets library, and never
showed up in Navidrome. This went unnoticed for weeks because most daily
syncs have nothing new to import, so the broken code path rarely ran.

Fixed the same copy-pasted PATH line in upgrade-mp3-to-flac.sh (which also
calls beet directly) and notify-telegram.sh (harmless there, but fixed for
consistency).

Also moved the post-import duplicate cleanup (replace-with-better.sh and
dedup-library.sh) out of sync-bandcamp.sh and into import-track.sh itself, so
every import gets the same cleanup, not just Bandcamp purchases. A manual
import or SMB drop that happens to match something already in the library no
longer leaves a duplicate copy sitting there until someone runs the dedup
review by hand.
2026-07-23 10:09:18 -06:00
andrew 068e7e9534 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.
2026-07-22 13:09:34 -06:00
andrew e07e931c45 0.6.12: Redesign the dedup review table for faster, more accurate scanning
The keep/delete columns were raw paths in a fixed-width cell: ellipsis-
truncated, full text only on hover. Slow to scan (mousing over every
row to read the song name) and, once "fixed" with a tag-derived title/
artist header in a first pass of this change, actively worse for the
thing dedup review actually needs -- confirming two files are really
the same recording. Tags can be wrong; the filename on disk can't.

New layout: each candidate gets a title row (song title, large,
unclipped -- parsed from beets' known singleton path template,
Artist/Album/Title.ext) followed by a compact detail row. The detail
row shows a color-coded format pill (FLAC/MP3/etc, matching the same
quality judgment dedup-library.sh's rank_file() already makes) and the
full relative file path, always rendered as visible text -- never
hover-only -- so a real difference in filename, album, or folder is
still plainly visible even when tags line up. Shared artist collapses
onto the title row so it isn't repeated per side; a colored left rail
marks which side survives without having to read the words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:15:27 -06:00
13 changed files with 323 additions and 97 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.11 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.11` 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.11 image: git.kretzer.club/andrew/alembic:0.6.14
container_name: alembic container_name: alembic
ports: ports:
- "8420:8420" - "8420:8420"
+68 -5
View File
@@ -1,3 +1,6 @@
import os
import re
from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -30,16 +33,76 @@ def _display_path(path: str) -> str:
return path[len(_LIBRARY_PREFIX):] if path.startswith(_LIBRARY_PREFIX) else path return path[len(_LIBRARY_PREFIX):] if path.startswith(_LIBRARY_PREFIX) else path
# Compilation-style import paths carry a "$track " prefix on the filename
# (see pipeline/configs/beets config.yaml paths: comp/albumtype_soundtrack);
# singleton paths (the vast majority of this library) don't. Strip it either
# way so the title reads clean.
_TRACK_PREFIX_RE = re.compile(r"^\d{1,3}[\s.\-]+")
# rank_file() in dedup-library.sh always ranks FLAC above every other format
# and WAV below MP3 (a download glitch, never desirable) -- mirror that
# judgment in the format pill's color so it reads as "this one's the keeper"
# at a glance, not just a bare extension string.
_FORMAT_BADGE = {"flac": "badge-success", "wav": "badge-warning"}
def _split_display(path: str) -> dict:
"""Break a library display path into (artist, album, title, ext).
Beets' singleton path template is always
%the{$albumartist}/$album/$title (pipeline/configs/beets config.yaml,
paths:) -- every track in this library lands at exactly that shape, so
the last two segments are reliably album/filename. A path that doesn't
fit (an unexpected root-level file) degrades to showing the raw display
path as the title instead of guessing at structure that isn't there.
"""
display = _display_path(path)
parts = display.split("/")
if len(parts) >= 2:
artist, album, filename = parts[0], parts[-2], parts[-1]
title, ext = os.path.splitext(filename)
title = _TRACK_PREFIX_RE.sub("", title)
else:
artist, album = "", ""
title, ext = os.path.splitext(display)
return {
"path": path,
"display": display,
"artist": artist,
"album": album,
"title": title,
"ext": ext.lstrip(".").lower(),
}
def _file_size(path: str) -> int | None:
try:
return os.path.getsize(path)
except OSError:
return None
def _to_row(c) -> dict: def _to_row(c) -> dict:
keep = _split_display(c.keep_path)
delete = _split_display(c.delete_path)
keep["size_bytes"] = _file_size(c.keep_path)
keep["badge"] = _FORMAT_BADGE.get(keep["ext"], "badge-muted")
delete["size_bytes"] = c.delete_size_bytes if c.delete_size_bytes is not None else _file_size(c.delete_path)
delete["badge"] = _FORMAT_BADGE.get(delete["ext"], "badge-muted")
return { return {
"id": c.id, "id": c.id,
"pass_name": c.pass_name, "pass_name": c.pass_name,
"pass_label": _pass_label(c.pass_name), "pass_label": _pass_label(c.pass_name),
"keep_path": c.keep_path, "keep": keep,
"delete_path": c.delete_path, "delete": delete,
"keep_display": _display_path(c.keep_path), # Same-tag passes (numbered_sibling/case_insensitive/normalized) share
"delete_display": _display_path(c.delete_path), # artist/title almost always; cross_album_fuzzy and fuzzy_audio can
"delete_size_bytes": c.delete_size_bytes, # legitimately differ (different credit, different album entirely) --
# the template only collapses shared context onto one line when it's
# actually shared, otherwise shows both sides in full.
"same_title": keep["title"].lower() == delete["title"].lower(),
"same_artist": keep["artist"].lower() == delete["artist"].lower(),
"same_album": keep["album"].lower() == delete["album"].lower(),
} }
+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": (
+61 -5
View File
@@ -475,11 +475,60 @@ tbody tr:hover { background: rgba(167, 139, 250, 0.055); }
tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; } tbody td.actions-cell { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.dedup-table { table-layout: fixed; } .dedup-table { table-layout: fixed; }
.dedup-table .path-cell {
overflow: hidden; /* Each candidate is its own <tbody> (title row + detail row) so the pair
text-overflow: ellipsis; highlights together on hover, and so :hover can bubble from either row
white-space: nowrap; up to the shared group without JS. That means every detail row is now
max-width: 0; /* forces the cell to respect the colgroup width instead of the content's natural width */ a tbody's last-child, which would otherwise strip the separator between
one candidate and the next (the generic `tbody tr:last-child` rule above
assumed one shared tbody) -- restore it here, and only drop it for the
actual last group in the table. */
.dedup-table .dedup-row-group:hover td { background: rgba(167, 139, 250, 0.06); }
.dedup-table .dedup-detail-row td { border-bottom: 1px solid var(--border); }
.dedup-table .dedup-row-group:last-of-type .dedup-detail-row td { border-bottom: none; }
.dedup-title-row td { padding: 0.75rem 1.1rem 0.2rem; border-bottom: none; }
.dedup-detail-row td { padding-top: 0.15rem; }
.dedup-title {
font-family: var(--font-display);
font-size: 1.02rem;
font-weight: 700;
color: var(--fg);
line-height: 1.35;
overflow-wrap: anywhere;
}
.dedup-title-alt { font-weight: 400; color: var(--muted); }
.dedup-context { font-size: 0.8rem; margin-top: 0.15rem; }
/* A thin colored rail on each side echoes the keep/delete verdict itself --
green for the copy that survives, muted-pink for the one on the chopping
block -- so which side is which reads before you've even read the words. */
.dedup-side {
display: flex;
align-items: center;
gap: 0.55rem;
padding-left: 0.65rem;
border-left: 2px solid transparent;
min-height: 1.6rem;
}
.dedup-side-keep { border-left-color: var(--status-success); }
.dedup-side-delete { border-left-color: var(--status-danger); }
.dedup-side-size { font-size: 0.78rem; white-space: nowrap; flex-shrink: 0; }
/* The actual filename/path -- always visible, never truncated. Tags can be
wrong; this is the ground truth for judging whether two files are really
the same recording, so it can't be hover-only the way it was in the first
pass of this redesign. */
.dedup-side-path {
padding-left: 0.65rem;
margin-top: 0.2rem;
font-size: 0.78rem;
color: var(--muted-2);
overflow-wrap: anywhere;
line-height: 1.4;
} }
.empty-row td { padding: 2.5rem 1rem; text-align: center; } .empty-row td { padding: 2.5rem 1rem; text-align: center; }
@@ -509,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 {
+75 -46
View File
@@ -1,5 +1,65 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dedup — alembic{% endblock %} {% block title %}Dedup — alembic{% endblock %}
{% macro candidate_rows(c, mode) %}
<tbody class="dedup-row-group">
<tr class="dedup-title-row">
<td colspan="{{ 5 if mode == 'pending' else 4 }}">
{% if c.same_title %}
<div class="dedup-title">{{ c.keep.title }}</div>
{% else %}
<div class="dedup-title">{{ c.keep.title }} <span class="dedup-title-alt">/ {{ c.delete.title }}</span></div>
{% endif %}
{% if c.same_artist and c.same_album and c.keep.album %}
<div class="dedup-context muted">{{ c.keep.artist }} · {{ c.keep.album }}</div>
{% elif c.same_artist %}
<div class="dedup-context muted">{{ c.keep.artist }}</div>
{% else %}
<div class="dedup-context muted">{{ c.keep.artist }} / {{ c.delete.artist }}</div>
{% endif %}
</td>
</tr>
<tr class="dedup-detail-row">
{% if mode == 'pending' %}
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td>
{% endif %}
<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>
<div class="dedup-side dedup-side-keep">
<span class="badge {{ c.keep.badge }}">{{ c.keep.ext or '?' }}</span>
{% if c.keep.size_bytes %}<span class="muted dedup-side-size">{{ (c.keep.size_bytes / 1024 / 1024) | round(1) }} MB</span>{% endif %}
</div>
<div class="dedup-side-path mono muted">{{ c.keep.display }}</div>
</td>
<td>
<div class="dedup-side dedup-side-delete">
<span class="badge {{ c.delete.badge }}">{{ c.delete.ext or '?' }}</span>
{% if c.delete.size_bytes %}<span class="muted dedup-side-size">{{ (c.delete.size_bytes / 1024 / 1024) | round(1) }} MB</span>{% endif %}
</div>
<div class="dedup-side-path mono muted">{{ c.delete.display }}</div>
</td>
<td class="actions-cell">
{% if mode == 'pending' %}
<form method="post" action="/dedup/confirm" class="inline"
onsubmit="return confirm('Permanently delete this file from disk?\n{{ c.delete.display }}')">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<form method="post" action="/dedup/ignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost" title="Keep both copies and never flag this pair again">Keep both</button>
</form>
{% else %}
<form method="post" action="/dedup/unignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost">Un-ignore</button>
</form>
{% endif %}
</td>
</tr>
</tbody>
{% endmacro %}
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<div> <div>
@@ -18,48 +78,30 @@
{% endif %} {% endif %}
<h2>Pending candidates</h2> <h2>Pending candidates</h2>
<p class="muted" style="margin-top:-0.5rem;">Paths are relative to the library root. Hover a name for the full path.</p> <p class="muted" style="margin-top:-0.5rem;">Song title up top so you can scan straight down the list; the actual file path is always shown underneath each side so you can check they're really the same file before confirming.</p>
<form id="bulk-delete-form" method="post" action="/dedup/confirm" <form id="bulk-delete-form" method="post" action="/dedup/confirm"
onsubmit="return confirm('Permanently delete every selected file from disk? This cannot be undone.')"></form> onsubmit="return confirm('Permanently delete every selected file from disk? This cannot be undone.')"></form>
<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:28%"> <col style="width:2.2rem"><col style="width:10.75rem"><col style="width:37.5%">
<col style="width:28%"><col style="width:5rem"><col style="width:9rem"> <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>Size</th><th>Action</th></tr> <tr><th></th><th>Caught by</th><th>Keep</th><th>Delete</th><th>Action</th></tr>
</thead> </thead>
{% for c in candidates %}
{{ candidate_rows(c, 'pending') }}
{% else %}
<tbody> <tbody>
{% set pass_badge = {"File Naming": "badge-info", "Acoustically Similar": "badge-warning"} %} <tr class="empty-row"><td colspan="5">
{% for c in candidates %}
<tr>
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}" form="bulk-delete-form"></td>
<td><span class="badge {{ pass_badge.get(c.pass_label, 'badge-info') }}" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
<td class="mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
<td class="actions-cell">
<form method="post" action="/dedup/confirm" class="inline"
onsubmit="return confirm('Permanently delete this file from disk?\n{{ c.delete_display }}')">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<form method="post" action="/dedup/ignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost" title="Keep both copies and never flag this pair again">Keep both</button>
</form>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">
<div class="empty-state"> <div class="empty-state">
<span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span> <span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span>
<p class="muted" style="margin:0;">No pending candidates. Run a scan.</p> <p class="muted" style="margin:0;">No pending candidates. Run a scan.</p>
</div> </div>
</td></tr> </td></tr>
{% endfor %}
</tbody> </tbody>
{% endfor %}
</table> </table>
</div> </div>
{% if candidates %} {% if candidates %}
@@ -72,28 +114,15 @@
<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:33%"> <col style="width:10.75rem"><col style="width:39.25%">
<col style="width:33%"><col style="width:5rem"><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>Size</th><th></th></tr> <tr><th>Caught by</th><th>Keep</th><th>Delete</th><th></th></tr>
</thead> </thead>
<tbody> {% for c in ignored %}
{% for c in ignored %} {{ candidate_rows(c, 'ignored') }}
<tr> {% endfor %}
<td><span class="badge badge-muted" title="{{ c.pass_name }}">{{ c.pass_label }}</span></td>
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
<td class="muted mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
<td>
<form method="post" action="/dedup/unignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost">Un-ignore</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>
{% endif %} {% endif %}
+24
View File
@@ -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"
+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...")
+3 -1
View File
@@ -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
+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
+13 -25
View File
@@ -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
+41 -5
View File
@@ -19,6 +19,42 @@
# which is the lesser of two evils vs. corrupting real band names. # which is the lesser of two evils vs. corrupting real band names.
_PRIMARY_ARTIST_SEP_PATTERN=' / | feat\. | feat | ft\. | ft |; ' _PRIMARY_ARTIST_SEP_PATTERN=' / | feat\. | feat | ft\. | ft |; '
# Read one ID3 text frame (e.g. TPE1, TIT2, TPE2) via mutagen instead of the
# `id3v2` CLI. id3v2 is id3lib-3.8.3 (last released 2013) and cannot parse
# ID3v2.4 tags at all -- it reports "No ID3 tag" on a file that has complete,
# valid tags, just written in the modern version most taggers (Mp3tag,
# yt-dlp, foobar2000) default to. That false negative used to send fully-
# tagged mp3s to quarantine. Usage: _mp3_tag <file> <frame>
_mp3_tag() {
python3 - "$1" "$2" 2>/dev/null <<'PY'
import sys
from mutagen.id3 import ID3
try:
tags = ID3(sys.argv[1])
frame = tags.get(sys.argv[2])
print(str(frame) if frame else "")
except Exception:
print("")
PY
}
# Set one ID3 text frame via mutagen, preserving every other frame on the
# file. The `id3v2` CLI cannot be used for this: writing even a single frame
# with it to a file that already has an ID3v2.4 tag silently drops every
# other frame (title, album, genre, date, artwork -- all of it), because
# id3lib doesn't understand v2.4 and rewrites the whole tag as v2.3 with only
# the frame(s) it was told to set. Usage: _mp3_set_tag <file> <frame> <value>
_mp3_set_tag() {
python3 - "$1" "$2" "$3" <<'PY'
import sys
from mutagen.id3 import ID3, Frames
tags = ID3(sys.argv[1])
frame_cls = Frames[sys.argv[2]]
tags.setall(sys.argv[2], [frame_cls(encoding=3, text=[sys.argv[3]])])
tags.save(sys.argv[1])
PY
}
# For each audio file under $scan_dir that has no ALBUMARTIST tag, set # For each audio file under $scan_dir that has no ALBUMARTIST tag, set
# ALBUMARTIST to the primary artist (everything before the first separator # ALBUMARTIST to the primary artist (everything before the first separator
# in the ARTIST tag). Prevents Navidrome from coining ghost album-artists # in the ARTIST tag). Prevents Navidrome from coining ghost album-artists
@@ -45,12 +81,12 @@ set_albumartist_fallback() {
;; ;;
mp3|MP3) mp3|MP3)
local cur_aa artist primary local cur_aa artist primary
cur_aa=$(id3v2 -l "$file" 2>/dev/null | sed -n 's/^TPE2[^:]*: //p' | head -1) cur_aa=$(_mp3_tag "$file" TPE2)
[[ -n "$cur_aa" ]] && continue [[ -n "$cur_aa" ]] && continue
artist=$(id3v2 -l "$file" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1) artist=$(_mp3_tag "$file" TPE1)
[[ -z "$artist" ]] && continue [[ -z "$artist" ]] && continue
primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##") primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##")
id3v2 --TPE2 "$primary" "$file" 2>>"$logfile" _mp3_set_tag "$file" TPE2 "$primary" 2>>"$logfile"
count=$((count + 1)) count=$((count + 1))
;; ;;
esac esac
@@ -76,8 +112,8 @@ quarantine_untagged() {
_has_mp3_tags() { _has_mp3_tags() {
local artist title local artist title
artist=$(id3v2 -l "$1" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1) artist=$(_mp3_tag "$1" TPE1)
title=$(id3v2 -l "$1" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1) title=$(_mp3_tag "$1" TIT2)
[[ -n "$artist" && -n "$title" ]] [[ -n "$artist" && -n "$title" ]]
} }
+2 -1
View File
@@ -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