68cb007e4c
Moves all ~30 pipeline scripts, configs, and the vendored sldl binary into this repo (source /opt/sldl left untouched). Removes all docker exec/docker compose dependencies now that beets and sldl run in-process/as a subprocess of this container instead of via soulbeet/on-demand sldl containers. Replaces hardcoded host paths, Navidrome credentials, and Spotify credential sourcing with env-var-driven paths and shared credential loaders. Adds Dockerfile, entrypoint.sh, requirements.txt, docker-compose.snippet.yml, and the initial app DB schema.
192 lines
6.5 KiB
Python
Executable File
192 lines
6.5 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)."""
|
|
try:
|
|
proc = subprocess.run(
|
|
["fpcalc", "-raw", host_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return None
|
|
if proc.returncode != 0:
|
|
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 failed == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|