b135f11557
Security (P0): - Remove committed session-secret default; auto-generate and persist a random secret to the config volume when SESSION_SECRET is unset (prevents forgeable session cookies / auth bypass). - Validate playlist names to a safe charset and render sldl configs via literal Python substitution instead of sed (closes a command-injection and path-traversal path through playlist names). - shlex-quote credential values written to shell-sourced env files, and strip newlines from values patched into .conf files. - Render playlist .conf files 0600; warn at startup if the master key is co-located with the config volume; document keeping it separate. Portability: - Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton. - Remove personal defaults (navidrome user "andrew", ephemeral.club URLs). - Ship generic example seeds; move the cross-album dedup keep-list and the legacy playlist import to editable config files; drop the personal _upgrade.csv. - Generic VPN reference in docker-compose.snippet.yml. First-run experience: - Redirect to /setup instead of 500 when OIDC is unconfigured; surface a missing master key inline; entrypoint exits with an actionable message when the config folder is not writable. - Add unauthenticated /health (JSON) and /setup (checklist) diagnostics. Docs: - Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced but missing); expand README with ownership, backups, advanced settings, and migration guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1001 lines
37 KiB
Python
Executable File
1001 lines
37 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
import-dj-collection.py — bulk import the DJ collection from $MUSIC_DATA_DIR/djstuff/
|
|
into the beets library + Navidrome dj-* playlists, deduping incoming files against
|
|
the library via Chromaprint fingerprints.
|
|
|
|
Two phases:
|
|
--dry-run (default): fingerprint each DJ file, compare against library, classify
|
|
as DUPLICATE / NEW / AMBIGUOUS. Write decisions to a state
|
|
SQLite DB. Emit a per-folder summary. Make no changes.
|
|
--apply : for each pending state-DB entry, take action:
|
|
DUPLICATE → add existing library track to dj-<playlist>
|
|
in Navidrome via Subsonic API
|
|
NEW → stage in /sldl-dropbox/djstuff/<slug>/,
|
|
tag with GROUPING=djstuff + a unique COMMENT
|
|
token, run beets import, find the resulting
|
|
library path, add to dj-<playlist>
|
|
AMBIGUOUS → skip (listed in CSV for manual handling)
|
|
|
|
The LDSxSupreme/ folder is hard-skipped per plan.
|
|
|
|
Usage:
|
|
import-dj-collection.py [--dry-run | --apply] [--folder NAME]
|
|
[--workers N] [--limit N] [--reset]
|
|
|
|
Examples:
|
|
import-dj-collection.py --dry-run --folder Bass
|
|
import-dj-collection.py --apply --folder Bass
|
|
import-dj-collection.py --dry-run # all folders, dry
|
|
import-dj-collection.py --apply # all folders, apply
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import multiprocessing
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass
|
|
from typing import Iterable
|
|
|
|
import numpy as np
|
|
|
|
# ============================================================================
|
|
# Paths and constants
|
|
# ============================================================================
|
|
|
|
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
|
|
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
|
|
|
|
DJSTUFF_ROOT = f"{_MUSIC_DATA_DIR}/djstuff"
|
|
SKIP_FOLDERS = {"LDSxSupreme"} # hard-skip per plan
|
|
|
|
# beets' directory: config may still be the transitional "/music" mount
|
|
# (Stages 0-3) or the post-migration MUSIC_DATA_DIR/Library path (Stage 4+);
|
|
# these two translators bridge whichever one beets/Navidrome are using to
|
|
# this container's own filesystem view. Once beets' directory: matches
|
|
# LIBRARY_HOST_ROOT (post Stage-4), container_to_host() becomes a no-op.
|
|
LIBRARY_HOST_ROOT = f"{_MUSIC_DATA_DIR}/Library/"
|
|
LIBRARY_CONTAINER_ROOT = "/music/"
|
|
|
|
DROPBOX_HOST_ROOT = f"{_MUSIC_DATA_DIR}/sldl-dropbox"
|
|
DROPBOX_DJSTUFF_HOST = f"{DROPBOX_HOST_ROOT}/djstuff"
|
|
|
|
FINGERPRINT_DB = f"{_ALEMBIC_CONFIG_DIR}/beets/fingerprints.db"
|
|
STATE_DB = f"{_ALEMBIC_CONFIG_DIR}/beets/dj-import-state.db"
|
|
BEETS_DB = f"{_ALEMBIC_CONFIG_DIR}/beets/data/beets_library.db"
|
|
NAVIDROME_DB = f"{_ALEMBIC_CONFIG_DIR}/navidrome/navidrome.db"
|
|
|
|
LOG_DIR = f"{_ALEMBIC_CONFIG_DIR}/logs"
|
|
|
|
# Load Navidrome admin creds the same way the shell scripts do.
|
|
def _load_navidrome_env():
|
|
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
|
|
env = {}
|
|
if os.path.exists(path):
|
|
for line in open(path):
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
env[k] = v.strip().strip("'").strip('"')
|
|
return env
|
|
|
|
_nd_env = _load_navidrome_env()
|
|
|
|
# Subsonic
|
|
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
|
|
ND_USER = _nd_env.get("ND_USER", "")
|
|
ND_PASS = _nd_env.get("ND_PASS", "")
|
|
ND_API_VERSION = "1.16.0"
|
|
ND_CLIENT = "dj-import"
|
|
|
|
# Audio extensions we'll process (everything else is ignored)
|
|
AUDIO_EXTS = {".flac", ".mp3", ".wav", ".m4a", ".aiff", ".aif", ".ogg"}
|
|
|
|
# Classification thresholds (acoustid.compare_fingerprints returns 0.0..1.0)
|
|
DUPLICATE_THRESHOLD = 0.92
|
|
AMBIGUOUS_THRESHOLD = 0.80
|
|
|
|
DEFAULT_WORKERS = 8
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
def container_to_host(p: str) -> str:
|
|
if p.startswith(LIBRARY_CONTAINER_ROOT):
|
|
return LIBRARY_HOST_ROOT + p[len(LIBRARY_CONTAINER_ROOT):]
|
|
return p
|
|
|
|
|
|
def host_to_navidrome_path(host_path: str) -> str:
|
|
"""Navidrome stores `media_file.path` relative to its music root."""
|
|
if host_path.startswith(LIBRARY_HOST_ROOT):
|
|
return host_path[len(LIBRARY_HOST_ROOT):]
|
|
if host_path.startswith(LIBRARY_CONTAINER_ROOT):
|
|
return host_path[len(LIBRARY_CONTAINER_ROOT):]
|
|
return host_path
|
|
|
|
|
|
def slugify_folder(name: str) -> str:
|
|
"""Folder name → 'dj-<slug>'. Lowercase, &→and, non-alnum→-, collapse repeats."""
|
|
s = name.replace("&", "and").lower()
|
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
s = s.strip("-")
|
|
return f"dj-{s}"
|
|
|
|
|
|
def short_hash(path: str) -> str:
|
|
"""Hash of the first 1 MB of a file — used to key state entries stably."""
|
|
h = hashlib.sha256()
|
|
try:
|
|
with open(path, "rb") as f:
|
|
h.update(f.read(1024 * 1024))
|
|
except OSError:
|
|
return ""
|
|
return h.hexdigest()[:16]
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
# ============================================================================
|
|
# Subsonic client (token + salt auth)
|
|
# ============================================================================
|
|
|
|
class Subsonic:
|
|
def __init__(self, base: str, user: str, password: str):
|
|
self.base = base.rstrip("/")
|
|
self.user = user
|
|
self.password = password
|
|
|
|
def _auth_params(self) -> dict[str, str]:
|
|
# Use plain-password auth — same convention as existing scripts.
|
|
return {
|
|
"u": self.user,
|
|
"p": self.password,
|
|
"v": ND_API_VERSION,
|
|
"c": ND_CLIENT,
|
|
"f": "xml",
|
|
}
|
|
|
|
def _call(self, endpoint: str, params: dict[str, str | list[str]]) -> ET.Element:
|
|
qs_parts: list[tuple[str, str]] = []
|
|
for k, v in {**self._auth_params(), **params}.items():
|
|
if isinstance(v, list):
|
|
for vv in v:
|
|
qs_parts.append((k, vv))
|
|
else:
|
|
qs_parts.append((k, v))
|
|
url = f"{self.base}/rest/{endpoint}.view?{urllib.parse.urlencode(qs_parts)}"
|
|
with urllib.request.urlopen(url, timeout=60) as r:
|
|
body = r.read()
|
|
root = ET.fromstring(body)
|
|
# Subsonic status check
|
|
if root.attrib.get("status") != "ok":
|
|
err = root.find("{http://subsonic.org/restapi}error")
|
|
msg = err.attrib.get("message", "unknown") if err is not None else "no error element"
|
|
raise RuntimeError(f"Subsonic {endpoint} failed: {msg}")
|
|
return root
|
|
|
|
def get_playlists(self) -> list[tuple[str, str]]:
|
|
root = self._call("getPlaylists", {})
|
|
ns = "{http://subsonic.org/restapi}"
|
|
return [
|
|
(p.attrib["id"], p.attrib["name"])
|
|
for p in root.iter(f"{ns}playlist")
|
|
]
|
|
|
|
def create_playlist(self, name: str) -> str:
|
|
root = self._call("createPlaylist", {"name": name})
|
|
ns = "{http://subsonic.org/restapi}"
|
|
pl = root.find(f"{ns}playlist")
|
|
if pl is None or "id" not in pl.attrib:
|
|
raise RuntimeError(f"createPlaylist returned no id for {name!r}")
|
|
return pl.attrib["id"]
|
|
|
|
def add_songs(self, playlist_id: str, song_ids: list[str]) -> None:
|
|
if not song_ids:
|
|
return
|
|
# Subsonic supports multiple songIdToAdd params; batch in chunks for URL length safety.
|
|
for i in range(0, len(song_ids), 50):
|
|
chunk = song_ids[i:i + 50]
|
|
self._call("updatePlaylist", {"playlistId": playlist_id, "songIdToAdd": chunk})
|
|
|
|
def start_scan(self) -> None:
|
|
self._call("startScan", {})
|
|
|
|
def get_scan_status(self) -> tuple[bool, int]:
|
|
root = self._call("getScanStatus", {})
|
|
ns = "{http://subsonic.org/restapi}"
|
|
s = root.find(f"{ns}scanStatus")
|
|
if s is None:
|
|
return False, 0
|
|
scanning = s.attrib.get("scanning", "false") == "true"
|
|
count = int(s.attrib.get("count", "0"))
|
|
return scanning, count
|
|
|
|
|
|
# ============================================================================
|
|
# Navidrome track-id lookup
|
|
# ============================================================================
|
|
|
|
def navidrome_path_to_id() -> dict[str, str]:
|
|
"""Snapshot Navidrome media_file table: relative_path → id."""
|
|
uri = f"file:{NAVIDROME_DB}?mode=ro"
|
|
conn = sqlite3.connect(uri, uri=True, timeout=30)
|
|
out: dict[str, str] = {}
|
|
for row in conn.execute("SELECT id, path FROM media_file WHERE missing = 0 OR missing IS NULL"):
|
|
out[row[1]] = row[0]
|
|
conn.close()
|
|
return out
|
|
|
|
|
|
# ============================================================================
|
|
# Fingerprint matching
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class LibraryEntry:
|
|
beets_id: int
|
|
host_path: str
|
|
duration: int
|
|
fingerprint: np.ndarray # uint32
|
|
|
|
|
|
def load_library_fingerprints() -> list[LibraryEntry]:
|
|
if not os.path.exists(FINGERPRINT_DB):
|
|
raise SystemExit(f"fingerprint index missing: {FINGERPRINT_DB}. "
|
|
f"Run build-fingerprint-index.py first.")
|
|
conn = sqlite3.connect(f"file:{FINGERPRINT_DB}?mode=ro", uri=True)
|
|
rows = list(conn.execute(
|
|
"SELECT beets_id, path, duration, fingerprint FROM fingerprints"
|
|
))
|
|
conn.close()
|
|
entries: list[LibraryEntry] = []
|
|
for beets_id, path, duration, fp in rows:
|
|
fp_arr = np.fromstring(fp, dtype=np.uint32, sep=",")
|
|
if fp_arr.size == 0:
|
|
continue
|
|
entries.append(LibraryEntry(beets_id, path, duration, fp_arr))
|
|
return entries
|
|
|
|
|
|
# Chromaprint comparison — bitwise popcount over aligned overlap, scanning
|
|
# offsets. Each fingerprint chunk is a 32-bit hash of an audio segment;
|
|
# matching bit fraction (1 - hamming/32) at the best alignment is the score.
|
|
def _compare_fingerprints(fp1: np.ndarray, fp2: np.ndarray, max_offset: int = 80) -> float:
|
|
if fp1.size < 50 or fp2.size < 50:
|
|
return 0.0
|
|
best = 0.0
|
|
for offset in range(-max_offset, max_offset + 1):
|
|
if offset >= 0:
|
|
x = fp1[:max(0, fp1.size - offset)]
|
|
y = fp2[offset:offset + x.size]
|
|
else:
|
|
x = fp1[-offset:]
|
|
y = fp2[:x.size]
|
|
n = int(min(x.size, y.size))
|
|
if n < 100:
|
|
continue
|
|
xor = np.bitwise_xor(x[:n], y[:n])
|
|
v = xor - ((xor >> 1) & np.uint32(0x55555555))
|
|
v = (v & np.uint32(0x33333333)) + ((v >> 2) & np.uint32(0x33333333))
|
|
v = (v + (v >> 4)) & np.uint32(0x0F0F0F0F)
|
|
popcount = ((v * np.uint32(0x01010101)) >> 24)
|
|
diff_bits = int(popcount.sum())
|
|
sim = 1.0 - diff_bits / (n * 32.0)
|
|
if sim > best:
|
|
best = sim
|
|
return best
|
|
|
|
|
|
def fpcalc_raw(path: str) -> tuple[int, np.ndarray] | None:
|
|
try:
|
|
proc = subprocess.run(
|
|
["fpcalc", "-raw", path],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return None
|
|
if proc.returncode != 0:
|
|
return None
|
|
duration = 0
|
|
fp_arr = np.empty(0, dtype=np.uint32)
|
|
for line in proc.stdout.splitlines():
|
|
if line.startswith("DURATION="):
|
|
try:
|
|
duration = int(line.split("=", 1)[1])
|
|
except ValueError:
|
|
pass
|
|
elif line.startswith("FINGERPRINT="):
|
|
fp_arr = np.fromstring(line.split("=", 1)[1], dtype=np.uint32, sep=",")
|
|
return (duration, fp_arr) if fp_arr.size > 0 else None
|
|
|
|
|
|
def best_match(
|
|
incoming: tuple[int, np.ndarray],
|
|
library: list[LibraryEntry],
|
|
) -> tuple[float, LibraryEntry | None]:
|
|
"""Return (best_score, best_entry). Filters candidates by duration proximity
|
|
(±15s) before scoring to skip obviously-unrelated tracks."""
|
|
incoming_dur, incoming_fp = incoming
|
|
best_score = 0.0
|
|
best_entry: LibraryEntry | None = None
|
|
for entry in library:
|
|
if abs(entry.duration - incoming_dur) > 15:
|
|
continue
|
|
try:
|
|
score = _compare_fingerprints(incoming_fp, entry.fingerprint)
|
|
except Exception:
|
|
continue
|
|
if score > best_score:
|
|
best_score = score
|
|
best_entry = entry
|
|
if score >= 0.99:
|
|
break
|
|
return best_score, best_entry
|
|
|
|
|
|
# ============================================================================
|
|
# State DB
|
|
# ============================================================================
|
|
|
|
def init_state_db(conn: sqlite3.Connection) -> None:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
incoming_path TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
folder TEXT NOT NULL,
|
|
target_playlist TEXT NOT NULL,
|
|
classification TEXT NOT NULL,
|
|
match_score REAL,
|
|
match_beets_id INTEGER,
|
|
match_path TEXT,
|
|
applied INTEGER NOT NULL DEFAULT 0,
|
|
apply_error TEXT,
|
|
applied_at TEXT,
|
|
PRIMARY KEY (incoming_path, content_hash)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_files_classification ON files(classification);
|
|
CREATE INDEX IF NOT EXISTS idx_files_applied ON files(applied);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
# ============================================================================
|
|
# Worker for parallel fingerprint+match
|
|
# ============================================================================
|
|
|
|
_library_cache: list[LibraryEntry] | None = None
|
|
|
|
|
|
def _worker_init(_library: list[LibraryEntry]) -> None:
|
|
global _library_cache
|
|
_library_cache = _library
|
|
|
|
|
|
def _worker_classify(args: tuple[str, str]) -> dict:
|
|
"""Run in worker pool. Returns dict with classification details for one file."""
|
|
incoming_path, folder = args
|
|
result = {
|
|
"incoming_path": incoming_path,
|
|
"folder": folder,
|
|
"content_hash": short_hash(incoming_path),
|
|
"classification": "ERROR",
|
|
"match_score": 0.0,
|
|
"match_beets_id": None,
|
|
"match_path": None,
|
|
}
|
|
fp = fpcalc_raw(incoming_path)
|
|
if fp is None:
|
|
result["classification"] = "ERROR"
|
|
return result
|
|
assert _library_cache is not None
|
|
score, entry = best_match(fp, _library_cache)
|
|
result["match_score"] = score
|
|
if entry is not None:
|
|
result["match_beets_id"] = entry.beets_id
|
|
result["match_path"] = entry.host_path
|
|
if score >= DUPLICATE_THRESHOLD:
|
|
result["classification"] = "DUPLICATE"
|
|
elif score >= AMBIGUOUS_THRESHOLD:
|
|
result["classification"] = "AMBIGUOUS"
|
|
else:
|
|
result["classification"] = "NEW"
|
|
return result
|
|
|
|
|
|
# ============================================================================
|
|
# Folder enumeration
|
|
# ============================================================================
|
|
|
|
def find_audio_in_folder(folder_path: str) -> list[str]:
|
|
"""Return audio file paths in folder_path. No recursion (kept folders are flat)."""
|
|
out: list[str] = []
|
|
for entry in os.scandir(folder_path):
|
|
if not entry.is_file():
|
|
continue
|
|
ext = os.path.splitext(entry.name)[1].lower()
|
|
if ext in AUDIO_EXTS:
|
|
out.append(entry.path)
|
|
return sorted(out)
|
|
|
|
|
|
def select_folders(filter_name: str | None) -> list[str]:
|
|
"""Return [top-level folder paths] under djstuff to process, skipping SKIP_FOLDERS."""
|
|
out: list[str] = []
|
|
for entry in sorted(os.scandir(DJSTUFF_ROOT), key=lambda e: e.name):
|
|
if not entry.is_dir():
|
|
continue
|
|
if entry.name in SKIP_FOLDERS:
|
|
continue
|
|
if filter_name is not None and entry.name != filter_name:
|
|
continue
|
|
out.append(entry.path)
|
|
return out
|
|
|
|
|
|
# ============================================================================
|
|
# Dry-run pass
|
|
# ============================================================================
|
|
|
|
def cmd_dry_run(args: argparse.Namespace) -> int:
|
|
log(f"loading library fingerprint index from {FINGERPRINT_DB}")
|
|
library = load_library_fingerprints()
|
|
log(f"library has {len(library)} fingerprints")
|
|
if len(library) < 100:
|
|
log("WARNING: library fingerprint index is unexpectedly small — "
|
|
"run build-fingerprint-index.py first.")
|
|
|
|
folders = select_folders(args.folder)
|
|
if not folders:
|
|
log("no folders matched")
|
|
return 1
|
|
log(f"processing folders: {[os.path.basename(f) for f in folders]}")
|
|
|
|
conn = sqlite3.connect(STATE_DB)
|
|
init_state_db(conn)
|
|
|
|
if args.reset:
|
|
conn.execute("DELETE FROM files WHERE applied = 0")
|
|
conn.commit()
|
|
log("reset: deleted unapplied state rows")
|
|
|
|
jobs: list[tuple[str, str]] = []
|
|
skipped_already_classified = 0
|
|
for folder_path in folders:
|
|
folder_name = os.path.basename(folder_path)
|
|
for f in find_audio_in_folder(folder_path):
|
|
content_hash = short_hash(f)
|
|
if not content_hash:
|
|
continue
|
|
row = conn.execute(
|
|
"SELECT classification FROM files WHERE incoming_path = ? AND content_hash = ?",
|
|
(f, content_hash),
|
|
).fetchone()
|
|
if row is not None and not args.reclassify:
|
|
skipped_already_classified += 1
|
|
continue
|
|
jobs.append((f, folder_name))
|
|
log(f"{len(jobs)} files to fingerprint+classify "
|
|
f"({skipped_already_classified} already in state DB; --reclassify to redo)")
|
|
if args.limit > 0:
|
|
jobs = jobs[:args.limit]
|
|
log(f"--limit {args.limit} applied")
|
|
if not jobs:
|
|
conn.close()
|
|
return 0
|
|
|
|
start = time.time()
|
|
done = 0
|
|
with multiprocessing.Pool(
|
|
args.workers, initializer=_worker_init, initargs=(library,)
|
|
) as pool:
|
|
for r in pool.imap_unordered(_worker_classify, jobs, chunksize=1):
|
|
done += 1
|
|
folder = r["folder"]
|
|
target = slugify_folder(folder)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO files (incoming_path, content_hash, folder, target_playlist,
|
|
classification, match_score, match_beets_id, match_path,
|
|
applied)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT(incoming_path, content_hash) DO UPDATE SET
|
|
folder=excluded.folder,
|
|
target_playlist=excluded.target_playlist,
|
|
classification=excluded.classification,
|
|
match_score=excluded.match_score,
|
|
match_beets_id=excluded.match_beets_id,
|
|
match_path=excluded.match_path
|
|
""",
|
|
(r["incoming_path"], r["content_hash"], folder, target,
|
|
r["classification"], r["match_score"],
|
|
r["match_beets_id"], r["match_path"]),
|
|
)
|
|
if done % 50 == 0:
|
|
conn.commit()
|
|
rate = done / max(time.time() - start, 0.01)
|
|
log(f" {done}/{len(jobs)} ({rate:.1f}/s)")
|
|
conn.commit()
|
|
log(f"classified {done} files in {time.time() - start:.0f}s")
|
|
|
|
write_dry_run_summary(conn, args.folder)
|
|
conn.close()
|
|
return 0
|
|
|
|
|
|
def write_dry_run_summary(conn: sqlite3.Connection, folder_filter: str | None) -> None:
|
|
"""Write a per-folder summary + ambiguous CSV to $ALEMBIC_CONFIG_DIR/logs/."""
|
|
os.makedirs(LOG_DIR, exist_ok=True)
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
summary_path = f"{LOG_DIR}/dj-import-dry-{stamp}.log"
|
|
ambig_csv_path = f"{LOG_DIR}/dj-import-ambiguous-{stamp}.csv"
|
|
|
|
where = ""
|
|
params: tuple = ()
|
|
if folder_filter is not None:
|
|
where = "WHERE folder = ?"
|
|
params = (folder_filter,)
|
|
|
|
rows = list(conn.execute(
|
|
f"SELECT folder, classification, COUNT(*) FROM files {where} "
|
|
f"GROUP BY folder, classification ORDER BY folder, classification",
|
|
params,
|
|
))
|
|
|
|
with open(summary_path, "w") as f:
|
|
f.write(f"# DJ import dry-run summary — {stamp}\n")
|
|
if folder_filter:
|
|
f.write(f"# Folder filter: {folder_filter}\n")
|
|
f.write("\n")
|
|
totals = {"DUPLICATE": 0, "NEW": 0, "AMBIGUOUS": 0, "ERROR": 0}
|
|
cur_folder = None
|
|
for folder, classification, n in rows:
|
|
if folder != cur_folder:
|
|
f.write(f"\n## {folder} → {slugify_folder(folder)}\n")
|
|
cur_folder = folder
|
|
f.write(f" {classification:10s} {n:5d}\n")
|
|
totals[classification] = totals.get(classification, 0) + n
|
|
f.write("\n## Totals\n")
|
|
for k in ("DUPLICATE", "NEW", "AMBIGUOUS", "ERROR"):
|
|
f.write(f" {k:10s} {totals[k]:5d}\n")
|
|
|
|
ambig_rows = list(conn.execute(
|
|
f"SELECT incoming_path, folder, target_playlist, match_score, match_path "
|
|
f"FROM files {where + ' AND ' if where else 'WHERE '}classification = 'AMBIGUOUS' "
|
|
f"ORDER BY match_score DESC",
|
|
params,
|
|
))
|
|
with open(ambig_csv_path, "w", newline="") as f:
|
|
w = csv.writer(f)
|
|
w.writerow(["incoming_path", "folder", "target_playlist", "match_score", "match_path"])
|
|
for r in ambig_rows:
|
|
w.writerow(r)
|
|
|
|
log(f"summary written to {summary_path}")
|
|
log(f"ambiguous list ({len(ambig_rows)} rows) written to {ambig_csv_path}")
|
|
|
|
|
|
# ============================================================================
|
|
# Apply pass
|
|
# ============================================================================
|
|
|
|
def cmd_apply(args: argparse.Namespace) -> int:
|
|
if not os.path.exists(STATE_DB):
|
|
log(f"state DB {STATE_DB} missing — run --dry-run first")
|
|
return 1
|
|
|
|
log("snapshotting Navidrome + beets DBs for rollback")
|
|
for src in [NAVIDROME_DB, BEETS_DB]:
|
|
dst = f"{src}.pre-dj-import.bak"
|
|
if not os.path.exists(dst):
|
|
shutil.copy2(src, dst)
|
|
log(f" {src} → {dst}")
|
|
else:
|
|
log(f" {dst} already exists; leaving as-is")
|
|
|
|
sub = Subsonic(ND_BASE, ND_USER, ND_PASS)
|
|
log("fetching existing playlists from Navidrome")
|
|
name_to_id = {name: pid for pid, name in sub.get_playlists()}
|
|
|
|
conn = sqlite3.connect(STATE_DB)
|
|
init_state_db(conn)
|
|
|
|
where = "applied = 0 AND classification IN ('DUPLICATE','NEW')"
|
|
params: tuple = ()
|
|
if args.folder is not None:
|
|
where += " AND folder = ?"
|
|
params = (args.folder,)
|
|
|
|
pending = list(conn.execute(
|
|
f"SELECT incoming_path, content_hash, folder, target_playlist, "
|
|
f"classification, match_path FROM files WHERE {where} ORDER BY folder, incoming_path",
|
|
params,
|
|
))
|
|
if args.limit > 0:
|
|
pending = pending[:args.limit]
|
|
log(f"{len(pending)} pending entries to apply")
|
|
if not pending:
|
|
conn.close()
|
|
return 0
|
|
|
|
# Group by classification for batched flow
|
|
dupes = [p for p in pending if p[4] == "DUPLICATE"]
|
|
news = [p for p in pending if p[4] == "NEW"]
|
|
log(f" {len(dupes)} duplicates → playlist add only")
|
|
log(f" {len(news)} new tracks → stage + beets import + playlist add")
|
|
|
|
# --- Pass 1: duplicates (no library write needed) -----------------------
|
|
apply_duplicates(sub, name_to_id, conn, dupes)
|
|
|
|
# --- Pass 2: new tracks --------------------------------------------------
|
|
if news:
|
|
apply_new_tracks(sub, name_to_id, conn, news)
|
|
|
|
# --- Post-pass housekeeping ---------------------------------------------
|
|
log("triggering Navidrome scan + waiting for completion")
|
|
sub.start_scan()
|
|
wait_for_scan(sub)
|
|
|
|
conn.close()
|
|
log("apply pass complete")
|
|
return 0
|
|
|
|
|
|
def apply_duplicates(sub: Subsonic, name_to_id: dict[str, str],
|
|
conn: sqlite3.Connection,
|
|
dupes: list[tuple]) -> None:
|
|
if not dupes:
|
|
return
|
|
log(f"=== duplicates pass: {len(dupes)} entries ===")
|
|
# Group by target playlist
|
|
by_pl: dict[str, list[tuple]] = {}
|
|
for row in dupes:
|
|
by_pl.setdefault(row[3], []).append(row)
|
|
|
|
path_to_nd = navidrome_path_to_id()
|
|
log(f"loaded {len(path_to_nd)} Navidrome track paths")
|
|
|
|
for target_pl, rows in by_pl.items():
|
|
pid = name_to_id.get(target_pl)
|
|
if pid is None:
|
|
log(f" creating playlist {target_pl}")
|
|
pid = sub.create_playlist(target_pl)
|
|
name_to_id[target_pl] = pid
|
|
|
|
# Collect Navidrome IDs to add
|
|
song_ids: list[str] = []
|
|
missing: list[str] = []
|
|
for (incoming_path, content_hash, folder, _tp, _cl, match_path) in rows:
|
|
nd_path = host_to_navidrome_path(match_path)
|
|
sid = path_to_nd.get(nd_path)
|
|
if sid is None:
|
|
missing.append(nd_path)
|
|
conn.execute(
|
|
"UPDATE files SET apply_error = ? WHERE incoming_path = ? AND content_hash = ?",
|
|
(f"navidrome id not found for {nd_path}", incoming_path, content_hash),
|
|
)
|
|
continue
|
|
song_ids.append(sid)
|
|
if missing:
|
|
log(f" {target_pl}: {len(missing)} library tracks not yet in Navidrome "
|
|
f"(scan needed; will retry on next apply run)")
|
|
|
|
if song_ids:
|
|
sub.add_songs(pid, song_ids)
|
|
log(f" {target_pl}: added {len(song_ids)} duplicate matches")
|
|
|
|
# Mark applied for the rows we successfully added
|
|
applied_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
applied_paths: set[tuple[str, str]] = set()
|
|
for (incoming_path, content_hash, _f, _tp, _cl, match_path) in rows:
|
|
nd_path = host_to_navidrome_path(match_path)
|
|
if path_to_nd.get(nd_path) is not None:
|
|
applied_paths.add((incoming_path, content_hash))
|
|
for ip, ch in applied_paths:
|
|
conn.execute(
|
|
"UPDATE files SET applied = 1, applied_at = ?, apply_error = NULL "
|
|
"WHERE incoming_path = ? AND content_hash = ?",
|
|
(applied_at, ip, ch),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def apply_new_tracks(sub: Subsonic, name_to_id: dict[str, str],
|
|
conn: sqlite3.Connection,
|
|
news: list[tuple]) -> None:
|
|
"""Stage + beets-import + playlist-add for files classified NEW."""
|
|
log(f"=== new tracks pass: {len(news)} entries ===")
|
|
os.makedirs(DROPBOX_DJSTUFF_HOST, exist_ok=True)
|
|
|
|
# Group by target playlist so beets imports a folder at a time.
|
|
by_pl: dict[str, list[tuple]] = {}
|
|
for row in news:
|
|
by_pl.setdefault(row[3], []).append(row)
|
|
|
|
staged: list[tuple[str, str, str, str, str]] = []
|
|
# (incoming_path, content_hash, target_playlist, token, staged_host_path)
|
|
|
|
for target_pl, rows in by_pl.items():
|
|
slug = target_pl # already 'dj-xxx'
|
|
stage_dir = f"{DROPBOX_DJSTUFF_HOST}/{slug}"
|
|
os.makedirs(stage_dir, exist_ok=True)
|
|
for (incoming_path, content_hash, folder, _tp, _cl, _mp) in rows:
|
|
token = f"djstuff-{uuid.uuid4().hex[:12]}"
|
|
staged_path = stage_track(incoming_path, stage_dir, token)
|
|
if staged_path is None:
|
|
conn.execute(
|
|
"UPDATE files SET apply_error = ? WHERE incoming_path = ? AND content_hash = ?",
|
|
("staging failed", incoming_path, content_hash),
|
|
)
|
|
conn.commit()
|
|
continue
|
|
staged.append((incoming_path, content_hash, target_pl, token, staged_path))
|
|
log(f"staged {len(staged)} files in {DROPBOX_DJSTUFF_HOST}/")
|
|
|
|
if not staged:
|
|
return
|
|
|
|
# beets runs in-process in this same container now, so it sees
|
|
# DROPBOX_DJSTUFF_HOST directly — no container path needed.
|
|
log(f"running beets import on {DROPBOX_DJSTUFF_HOST} (no autotag, no dup-check)")
|
|
proc = subprocess.run(
|
|
["beet", "import", "-q", "-s", "-A", DROPBOX_DJSTUFF_HOST],
|
|
capture_output=True, text=True,
|
|
)
|
|
log(f" beet import exit={proc.returncode}")
|
|
if proc.returncode != 0:
|
|
log(f" stderr: {proc.stderr[-500:]}")
|
|
|
|
# For each staged file, locate the resulting library track by token.
|
|
log("resolving imported tracks by COMMENT token")
|
|
resolved: dict[str, tuple[int, str]] = {} # token → (beets_id, container_path)
|
|
for (incoming_path, content_hash, target_pl, token, staged_path) in staged:
|
|
beets_id, container_path = lookup_by_comment_token(token)
|
|
if beets_id is None:
|
|
conn.execute(
|
|
"UPDATE files SET apply_error = ? WHERE incoming_path = ? AND content_hash = ?",
|
|
(f"beets did not import (token {token} missing)", incoming_path, content_hash),
|
|
)
|
|
continue
|
|
resolved[token] = (beets_id, container_path)
|
|
conn.commit()
|
|
log(f"resolved {len(resolved)}/{len(staged)} imported tracks")
|
|
|
|
# Trigger a Navidrome scan + wait so new tracks become addressable.
|
|
log("triggering Navidrome scan so new tracks get IDs")
|
|
sub.start_scan()
|
|
wait_for_scan(sub)
|
|
|
|
path_to_nd = navidrome_path_to_id()
|
|
|
|
# Add each new track to its target dj-* playlist.
|
|
log("adding new tracks to dj-* playlists in Navidrome")
|
|
by_pl_resolved: dict[str, list[tuple[str, str, str, str, str, int, str]]] = {}
|
|
for (incoming_path, content_hash, target_pl, token, staged_path) in staged:
|
|
r = resolved.get(token)
|
|
if r is None:
|
|
continue
|
|
beets_id, container_path = r
|
|
by_pl_resolved.setdefault(target_pl, []).append(
|
|
(incoming_path, content_hash, target_pl, token, staged_path, beets_id, container_path)
|
|
)
|
|
|
|
applied_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
for target_pl, rows in by_pl_resolved.items():
|
|
pid = name_to_id.get(target_pl)
|
|
if pid is None:
|
|
pid = sub.create_playlist(target_pl)
|
|
name_to_id[target_pl] = pid
|
|
song_ids: list[str] = []
|
|
for (_ip, _ch, _tp, _tok, _sp, _bid, container_path) in rows:
|
|
nd_path = host_to_navidrome_path(container_path)
|
|
sid = path_to_nd.get(nd_path)
|
|
if sid is not None:
|
|
song_ids.append(sid)
|
|
if song_ids:
|
|
sub.add_songs(pid, song_ids)
|
|
log(f" {target_pl}: added {len(song_ids)} new tracks")
|
|
# Mark applied
|
|
for (ip, ch, _tp, _tok, _sp, _bid, container_path) in rows:
|
|
nd_path = host_to_navidrome_path(container_path)
|
|
if path_to_nd.get(nd_path) is not None:
|
|
conn.execute(
|
|
"UPDATE files SET applied = 1, applied_at = ?, apply_error = NULL "
|
|
"WHERE incoming_path = ? AND content_hash = ?",
|
|
(applied_at, ip, ch),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE files SET apply_error = ? "
|
|
"WHERE incoming_path = ? AND content_hash = ?",
|
|
(f"navidrome id not found post-scan for {nd_path}", ip, ch),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def stage_track(src: str, stage_dir: str, token: str) -> str | None:
|
|
"""Copy src into stage_dir, convert WAV→FLAC, tag with GROUPING=djstuff and a
|
|
unique COMMENT token. Return the staged path on success, None on failure."""
|
|
base = os.path.basename(src)
|
|
dst = os.path.join(stage_dir, base)
|
|
try:
|
|
shutil.copy2(src, dst)
|
|
except OSError as e:
|
|
log(f" stage: copy failed for {src}: {e}")
|
|
return None
|
|
ext = os.path.splitext(dst)[1].lower()
|
|
# WAV → FLAC
|
|
if ext == ".wav":
|
|
flac_dst = dst[:-4] + ".flac"
|
|
proc = subprocess.run(
|
|
["flac", "--silent", "--best", "--delete-input-file", dst, "-o", flac_dst],
|
|
capture_output=True, text=True,
|
|
)
|
|
if proc.returncode != 0 or not os.path.exists(flac_dst):
|
|
log(f" stage: WAV→FLAC failed for {dst}: {proc.stderr[-200:]}")
|
|
return None
|
|
# Try to parse "Artist - Title" from filename for basic tags
|
|
stem = os.path.basename(flac_dst)[:-5]
|
|
if " - " in stem:
|
|
artist, title = stem.split(" - ", 1)
|
|
subprocess.run(
|
|
["metaflac",
|
|
f"--set-tag=ARTIST={artist}",
|
|
f"--set-tag=TITLE={title}",
|
|
f"--set-tag=ALBUM={title}",
|
|
flac_dst],
|
|
capture_output=True, text=True,
|
|
)
|
|
dst = flac_dst
|
|
ext = ".flac"
|
|
# Tag GROUPING=djstuff + COMMENT token
|
|
return tag_dj_track(dst, ext, token)
|
|
|
|
|
|
def tag_dj_track(path: str, ext: str, token: str) -> str | None:
|
|
"""Set GROUPING=djstuff + COMMENT=<token> on the staged file. Return path or None."""
|
|
try:
|
|
if ext == ".flac":
|
|
# beets/mediafile maps Vorbis DESCRIPTION (not COMMENT) to its
|
|
# `comments` field for FLAC. Bandcamp-sourced FLACs ship with
|
|
# DESCRIPTION=https://...bandcamp.com which would mask our token
|
|
# if we only wrote COMMENT. Strip + set both fields.
|
|
subprocess.run(
|
|
["metaflac",
|
|
"--remove-tag=GROUPING",
|
|
"--remove-tag=COMMENT",
|
|
"--remove-tag=DESCRIPTION",
|
|
path],
|
|
capture_output=True, text=True,
|
|
)
|
|
r = subprocess.run(
|
|
["metaflac",
|
|
f"--set-tag=GROUPING=djstuff",
|
|
f"--set-tag=COMMENT={token}",
|
|
f"--set-tag=DESCRIPTION={token}",
|
|
path],
|
|
capture_output=True, text=True,
|
|
)
|
|
if r.returncode != 0:
|
|
log(f" tag: metaflac failed on {path}: {r.stderr[-200:]}")
|
|
return None
|
|
elif ext == ".mp3":
|
|
# id3v2 frames: TIT1 = grouping, COMM = comment
|
|
subprocess.run(["id3v2", "--TIT1", "djstuff", path],
|
|
capture_output=True, text=True)
|
|
subprocess.run(["id3v2", "--COMM", token, path],
|
|
capture_output=True, text=True)
|
|
else:
|
|
# Use mutagen for other formats
|
|
try:
|
|
import mutagen
|
|
f = mutagen.File(path, easy=False)
|
|
if f is None:
|
|
log(f" tag: mutagen could not open {path}")
|
|
return None
|
|
# Easy-tag for grouping is format-dependent; use raw frames best-effort.
|
|
# For m4a (MP4): ©grp = grouping, ©cmt = comment.
|
|
if hasattr(f, "tags") and f.tags is not None:
|
|
try:
|
|
f.tags["©grp"] = ["djstuff"]
|
|
f.tags["©cmt"] = [token]
|
|
except Exception:
|
|
pass
|
|
f.save()
|
|
except Exception as e:
|
|
log(f" tag: mutagen failed on {path}: {e}")
|
|
return None
|
|
return path
|
|
except Exception as e:
|
|
log(f" tag: exception on {path}: {e}")
|
|
return None
|
|
|
|
|
|
def lookup_by_comment_token(token: str) -> tuple[int | None, str | None]:
|
|
"""Find the beets id + path for a staged file by its COMMENT token."""
|
|
proc = subprocess.run(
|
|
["beet", "ls", "-f", "$id|||$path", f"comments::{token}"],
|
|
capture_output=True, text=True,
|
|
)
|
|
if proc.returncode != 0:
|
|
return None, None
|
|
for line in proc.stdout.splitlines():
|
|
if "|||" in line:
|
|
id_str, path = line.split("|||", 1)
|
|
try:
|
|
return int(id_str), path.strip()
|
|
except ValueError:
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def wait_for_scan(sub: Subsonic, max_wait: int = 300) -> None:
|
|
start = time.time()
|
|
last_count = -1
|
|
while time.time() - start < max_wait:
|
|
try:
|
|
scanning, count = sub.get_scan_status()
|
|
except Exception as e:
|
|
log(f" scan status check failed: {e}")
|
|
time.sleep(3)
|
|
continue
|
|
if count != last_count:
|
|
log(f" scan in progress, count={count}")
|
|
last_count = count
|
|
if not scanning:
|
|
log(f" scan complete (count={count})")
|
|
return
|
|
time.sleep(3)
|
|
log(" scan wait timed out — proceeding anyway")
|
|
|
|
|
|
# ============================================================================
|
|
# CLI
|
|
# ============================================================================
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
grp = ap.add_mutually_exclusive_group()
|
|
grp.add_argument("--dry-run", action="store_true", default=True,
|
|
help="(default) fingerprint+classify; no changes")
|
|
grp.add_argument("--apply", action="store_true",
|
|
help="apply classified decisions: playlist add + beets import")
|
|
ap.add_argument("--folder", help="process only this top-level djstuff folder")
|
|
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
|
|
ap.add_argument("--limit", type=int, default=0)
|
|
ap.add_argument("--reset", action="store_true",
|
|
help="delete unapplied state rows before classifying (keeps applied=1 rows)")
|
|
ap.add_argument("--reclassify", action="store_true",
|
|
help="re-fingerprint files that already have a classification")
|
|
args = ap.parse_args()
|
|
|
|
os.makedirs(LOG_DIR, exist_ok=True)
|
|
|
|
if args.apply:
|
|
return cmd_apply(args)
|
|
return cmd_dry_run(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|