e5d9c7f043
- Add "Connect Spotify" OAuth flow (/connect/spotify) so newly created Spotify apps can read playlists: Spotify now requires a user token for playlist reads, which client-credentials alone can no longer provide. The stored refresh token is rendered as spotify-refresh into each playlist's sldl .conf -- sldl's own docs confirm supplying it skips its interactive login flow, which is what was causing multi-hour hangs on a stuck sync. Grandfathered apps keep working unchanged if no account is connected. - Fix the connect flow's redirect_uri: request.url_for() reflected the raw connection scheme (http) rather than what the reverse proxy actually served over, which Spotify's exact-match redirect_uri check rejected. Force https rather than trust the unproxied scheme. - Add an AzuraCast base URL credential field alongside the API key, with a keyfile fallback so the scheduled enrich-buy-url.py job can actually reach AzuraCast (previously it had no way to receive a base URL at all when run from the scheduler, so the reprocess call was silently always skipped). - Fix build-fingerprint-index.py exiting non-zero on any single fingerprint failure instead of only when nothing was written. - Guard enrich-buy-url.py's AzuraCast reprocess against an empty --azuracast-base (raised ValueError since PD2 removed the hardcoded base). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
6.9 KiB
Python
Executable File
197 lines
6.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
build-fingerprint-index.py — build/refresh a Chromaprint fingerprint index of
|
|
the beets library at $ALEMBIC_CONFIG_DIR/beets/fingerprints.db.
|
|
|
|
Reads paths from `beet ls -f '$id|||$path'` (beets now runs in-process in this
|
|
same container, so no container-to-host path translation is needed — the path
|
|
beets reports is already valid in this container's own filesystem view).
|
|
Skips entries whose (path, mtime, size) is unchanged. Parallel fpcalc workers
|
|
keep this under ~10 min for a 3k-track library.
|
|
|
|
Used by import-dj-collection.py to dedup incoming DJ files against the existing
|
|
library via acoustid.compare_fingerprints().
|
|
|
|
Usage:
|
|
build-fingerprint-index.py [--workers N] [--limit N]
|
|
"""
|
|
|
|
import argparse
|
|
import multiprocessing
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
|
|
DB_PATH = f"{ALEMBIC_CONFIG_DIR}/beets/fingerprints.db"
|
|
DEFAULT_WORKERS = 8
|
|
|
|
|
|
def init_db(conn: sqlite3.Connection) -> None:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS fingerprints (
|
|
beets_id INTEGER PRIMARY KEY,
|
|
path TEXT NOT NULL,
|
|
mtime REAL NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
duration INTEGER NOT NULL,
|
|
fingerprint TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_fp_path ON fingerprints(path);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def enumerate_library() -> list[tuple[int, str]]:
|
|
"""Return [(beets_id, path), ...] for every track in the library."""
|
|
cmd = ["beet", "ls", "-f", "$id|||$path"]
|
|
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
|
|
items: list[tuple[int, str]] = []
|
|
for line in out.splitlines():
|
|
line = line.rstrip("\r")
|
|
if "|||" not in line:
|
|
continue
|
|
id_str, path = line.split("|||", 1)
|
|
try:
|
|
beets_id = int(id_str)
|
|
except ValueError:
|
|
continue
|
|
items.append((beets_id, path))
|
|
return items
|
|
|
|
|
|
def fpcalc(host_path: str) -> tuple[int, str] | None:
|
|
"""Run fpcalc -raw on a file. Returns (duration_seconds, fingerprint_csv).
|
|
|
|
fpcalc's exit code is not a reliable success signal: it exits non-zero
|
|
(commonly 3) on a benign trailing-frame decode warning -- seen a lot on
|
|
game-soundtrack rips with a slightly non-conformant tail -- while still
|
|
printing a perfectly usable FINGERPRINT line. Whether a fingerprint was
|
|
actually produced is the real signal, checked below; the exit code is
|
|
ignored entirely."""
|
|
try:
|
|
proc = subprocess.run(
|
|
["fpcalc", "-raw", host_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return None
|
|
duration = 0
|
|
fingerprint = ""
|
|
for line in proc.stdout.splitlines():
|
|
if line.startswith("DURATION="):
|
|
try:
|
|
duration = int(line.split("=", 1)[1])
|
|
except ValueError:
|
|
pass
|
|
elif line.startswith("FINGERPRINT="):
|
|
fingerprint = line.split("=", 1)[1]
|
|
if not fingerprint:
|
|
return None
|
|
return duration, fingerprint
|
|
|
|
|
|
def worker(job: tuple[int, str, float, int]) -> tuple[int, str, float, int, int, str] | None:
|
|
beets_id, host_path, mtime, size = job
|
|
result = fpcalc(host_path)
|
|
if result is None:
|
|
return None
|
|
duration, fingerprint = result
|
|
return beets_id, host_path, mtime, size, duration, fingerprint
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
|
|
ap.add_argument("--limit", type=int, default=0, help="process at most N new files (0=all)")
|
|
ap.add_argument("--verbose", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
init_db(conn)
|
|
|
|
print(f"[index] enumerating library via beets...", flush=True)
|
|
library = enumerate_library()
|
|
print(f"[index] library has {len(library)} tracks", flush=True)
|
|
|
|
existing: dict[int, tuple[str, float, int]] = {}
|
|
for row in conn.execute("SELECT beets_id, path, mtime, size FROM fingerprints"):
|
|
existing[row[0]] = (row[1], row[2], row[3])
|
|
|
|
jobs: list[tuple[int, str, float, int]] = []
|
|
missing_count = 0
|
|
for beets_id, host_path in library:
|
|
try:
|
|
st = os.stat(host_path)
|
|
except FileNotFoundError:
|
|
missing_count += 1
|
|
continue
|
|
cur = existing.get(beets_id)
|
|
if cur is not None and cur[0] == host_path and abs(cur[1] - st.st_mtime) < 1 and cur[2] == st.st_size:
|
|
continue
|
|
jobs.append((beets_id, host_path, st.st_mtime, st.st_size))
|
|
|
|
print(f"[index] {len(jobs)} new/changed tracks to fingerprint "
|
|
f"({len(library) - len(jobs) - missing_count} already current, {missing_count} missing on disk)", flush=True)
|
|
if args.limit > 0:
|
|
jobs = jobs[:args.limit]
|
|
print(f"[index] --limit {args.limit} applied; processing {len(jobs)}", flush=True)
|
|
if not jobs:
|
|
conn.close()
|
|
return 0
|
|
|
|
purge_ids = set(existing.keys()) - {b for b, *_ in library}
|
|
if purge_ids:
|
|
conn.executemany("DELETE FROM fingerprints WHERE beets_id = ?", [(i,) for i in purge_ids])
|
|
conn.commit()
|
|
print(f"[index] purged {len(purge_ids)} stale rows", flush=True)
|
|
|
|
start = time.time()
|
|
done = 0
|
|
failed = 0
|
|
batch: list[tuple] = []
|
|
with multiprocessing.Pool(args.workers) as pool:
|
|
for result in pool.imap_unordered(worker, jobs, chunksize=4):
|
|
done += 1
|
|
if result is None:
|
|
failed += 1
|
|
else:
|
|
batch.append(result)
|
|
if len(batch) >= 50:
|
|
conn.executemany(
|
|
"INSERT OR REPLACE INTO fingerprints "
|
|
"(beets_id, path, mtime, size, duration, fingerprint) VALUES (?,?,?,?,?,?)",
|
|
batch,
|
|
)
|
|
conn.commit()
|
|
batch.clear()
|
|
if done % 100 == 0 or args.verbose:
|
|
elapsed = time.time() - start
|
|
rate = done / elapsed if elapsed > 0 else 0
|
|
print(f"[index] {done}/{len(jobs)} ({rate:.1f}/s, {failed} failed)", flush=True)
|
|
if batch:
|
|
conn.executemany(
|
|
"INSERT OR REPLACE INTO fingerprints "
|
|
"(beets_id, path, mtime, size, duration, fingerprint) VALUES (?,?,?,?,?,?)",
|
|
batch,
|
|
)
|
|
conn.commit()
|
|
|
|
total = conn.execute("SELECT COUNT(*) FROM fingerprints").fetchone()[0]
|
|
conn.close()
|
|
elapsed = time.time() - start
|
|
print(f"[index] done in {elapsed:.0f}s. wrote {done - failed} rows, {failed} failed. "
|
|
f"index now has {total} entries.", flush=True)
|
|
return 0 if (done - failed) > 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|