fix: address harness review blockers

This commit is contained in:
yuhao
2026-05-20 16:01:11 +00:00
parent 1dd4b4e40b
commit 5ceeb4b2e6
7 changed files with 204 additions and 34 deletions
+10 -12
View File
@@ -37,7 +37,8 @@ pip install cli-anything-rekordbox[windows]
cli-anything-rekordbox library count
cli-anything-rekordbox library search "Daft Punk"
# Create + populate a playlist
# Create + populate a playlist. Writes back up master.db first and refuse to run
# while Rekordbox is open unless --force is supplied.
cli-anything-rekordbox playlist create "MyMix"
cli-anything-rekordbox playlist add "MyMix" --track-title "Track A"
cli-anything-rekordbox playlist add "MyMix" --track-title "Track B"
@@ -68,17 +69,14 @@ cli-anything-rekordbox
| Command | Description |
|---------|-------------|
| `list` | Show all playlists |
| `create NAME` | Create new playlist |
| `delete NAME` | Delete playlist |
| `add NAME --track-title T` | Add track by title |
| `clear NAME` | Empty a playlist |
| `create NAME [--force] [--no-backup]` | Create new playlist |
| `add NAME --track-title T [--force] [--no-backup]` | Add track by title |
| `clear NAME [--force] [--no-backup]` | Empty a playlist |
### `cue`
| Command | Description |
|---------|-------------|
| `list TRACK_ID` | List all cue points |
| `set TRACK_ID --ms N --color HEX` | Add a cue at offset |
| `hot TRACK_ID --slot N --ms M` | Set hot cue |
Playlist writes create timestamped backups under `master.db`'s sibling
`cli-anything-backups/` directory before mutation. If Rekordbox is running, the
CLI refuses to write by default; `--force` acknowledges that risk but still
requires a backup. `--no-backup` is only accepted when Rekordbox is closed.
### `deck` (live MIDI)
| Command | Description |
@@ -135,7 +133,7 @@ $ cli-anything-rekordbox --json library search "Daft Punk"
## Notes & caveats
- **SQLCipher key** is auto-extracted by pyrekordbox from rekordbox.exe (the static rekordbox 6/7 master key, hardcoded across all installs).
- **Library writes** require either rekordbox closed OR bypassing pyrekordbox's running-rekordbox safety check (this CLI does the latter via `db.session.commit()` directly).
- **Library writes** require Rekordbox to be closed by default. Forced writes with `--force` are explicit, backed up first, and should be reserved for recovery/test workflows.
- **Live-deck control** depends on the user mapping the virtual MIDI port in rekordbox `Preferences → Controller → MIDI` (one-time UI step). The harness ships `Bunker.midi.csv` and an `install-mapping` command to drop it into the right folder.
- **Pioneer offers no playback REST API.** This is the closest thing.
@@ -30,5 +30,9 @@ cli-anything-rekordbox install-mapping
cli-anything-rekordbox deck crossfade 1 2 --secs 16
```
Playlist writes are guarded: the CLI creates a `master.db` backup before
mutation and refuses to write while Rekordbox is running unless `--force` is
provided. `--no-backup` is only accepted when Rekordbox is closed.
## License
MIT
@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
@@ -43,7 +44,7 @@ def _emit(ctx: click.Context, payload):
def _open_db():
"""Open rekordbox master.db. Bypasses 'rekordbox is running' safety on commits."""
"""Open rekordbox master.db."""
try:
from pyrekordbox import Rekordbox6Database
except ImportError:
@@ -53,8 +54,85 @@ def _open_db():
return Rekordbox6Database(unlock=True)
def _commit_force(db):
"""Bypass pyrekordbox's running-rekordbox check."""
def _is_rekordbox_running() -> bool:
"""Best-effort process check using only the Python standard library."""
override = os.environ.get("CLI_ANYTHING_REKORDBOX_RUNNING")
if override is not None:
return override.strip().lower() in {"1", "true", "yes", "on"}
try:
if sys.platform == "win32":
result = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq rekordbox.exe", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
check=False,
)
return "rekordbox.exe" in result.stdout.lower()
names = ["rekordbox", "Rekordbox"]
for name in names:
result = subprocess.run(["pgrep", "-x", name], capture_output=True, check=False)
if result.returncode == 0:
return True
except (FileNotFoundError, OSError):
pass
try:
result = subprocess.run(["ps", "-A", "-o", "comm="], capture_output=True, text=True, check=False)
except (FileNotFoundError, OSError):
return False
return any(line.strip().lower() == "rekordbox" for line in result.stdout.splitlines())
def _db_path(db) -> Path:
path = getattr(getattr(db, "engine", None), "url", None)
database = getattr(path, "database", None)
if not database:
raise click.ClickException("Could not determine rekordbox master.db path for backup")
return Path(database).expanduser()
def _backup_database(db) -> list[str]:
"""Copy master.db and SQLite sidecars before mutation."""
db_path = _db_path(db)
if not db_path.exists():
raise click.ClickException(f"Cannot back up missing database: {db_path}")
backup_dir = db_path.parent / "cli-anything-backups"
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S")
copied = []
for src in [db_path, Path(f"{db_path}-wal"), Path(f"{db_path}-shm")]:
if not src.exists():
continue
dst = backup_dir / f"{src.name}.{stamp}.bak"
shutil.copy2(src, dst)
copied.append(str(dst))
if not copied:
raise click.ClickException(f"No database files were backed up for {db_path}")
return copied
def _open_db_for_write(*, force: bool, backup: bool, operation: str):
running = _is_rekordbox_running()
if running and not force:
raise click.ClickException(
f"Refusing to {operation} while Rekordbox is running. Close Rekordbox first, "
"or rerun with --force to acknowledge the risk. Forced writes still create a backup."
)
if running and not backup:
raise click.ClickException("Refusing forced write while Rekordbox is running without a backup")
db = _open_db()
backup_paths = _backup_database(db) if backup else []
return db, {"backup": backup_paths, "rekordbox_running": running, "forced": force}
def _commit_db_write(db):
"""Commit a guarded DB write after the caller has checked process state and backed up."""
db.registry.autoincrement_local_update_count(set_row_usn=True)
db.session.commit()
db.registry.clear_buffer()
@@ -203,25 +281,30 @@ def playlist_list(ctx):
@playlist.command("create")
@click.argument("name")
@click.option("--force", is_flag=True, help="Allow write while Rekordbox is running after creating a backup.")
@click.option("--no-backup", is_flag=True, help="Skip the pre-write master.db backup when Rekordbox is closed.")
@click.pass_context
def playlist_create(ctx, name: str):
def playlist_create(ctx, name: str, force: bool, no_backup: bool):
"""Create a new playlist."""
db = _open_db()
existing = [p for p in db.get_playlist() if p.Name == name]
if existing:
_emit(ctx, {"playlist": name, "id": existing[0].ID, "status": "already exists"})
return
db, safety = _open_db_for_write(force=force, backup=not no_backup, operation=f"create playlist {name!r}")
pl = db.create_playlist(name)
_commit_force(db)
_emit(ctx, {"playlist": name, "id": pl.ID, "status": "created"})
_commit_db_write(db)
_emit(ctx, {"playlist": name, "id": pl.ID, "status": "created", **safety})
@playlist.command("add")
@click.argument("playlist_name")
@click.option("--track-title", required=True, help="Track title to search + add")
@click.option("--track-id", type=int, help="Track ID (skips title search)")
@click.option("--force", is_flag=True, help="Allow write while Rekordbox is running after creating a backup.")
@click.option("--no-backup", is_flag=True, help="Skip the pre-write master.db backup when Rekordbox is closed.")
@click.pass_context
def playlist_add(ctx, playlist_name: str, track_title: str, track_id: Optional[int]):
def playlist_add(ctx, playlist_name: str, track_title: str, track_id: Optional[int], force: bool, no_backup: bool):
"""Add a track to a playlist."""
db = _open_db()
pl = next((p for p in db.get_playlist() if p.Name == playlist_name), None)
@@ -233,26 +316,42 @@ def playlist_add(ctx, playlist_name: str, track_title: str, track_id: Optional[i
track = next((c for c in db.get_content() if c.Title and track_title.lower() in c.Title.lower()), None)
if not track:
raise click.ClickException(f"track not found")
db, safety = _open_db_for_write(force=force, backup=not no_backup, operation=f"add track to playlist {playlist_name!r}")
pl = next((p for p in db.get_playlist() if p.Name == playlist_name), None)
if not pl:
raise click.ClickException(f"playlist {playlist_name!r} not found")
track = next((c for c in db.get_content() if c.ID == track_id), None) if track_id else next(
(c for c in db.get_content() if c.Title and track_title.lower() in c.Title.lower()),
None,
)
if not track:
raise click.ClickException("track not found")
db.add_to_playlist(pl, track)
_commit_force(db)
_emit(ctx, {"playlist": playlist_name, "added_track_id": track.ID, "title": track.Title})
_commit_db_write(db)
_emit(ctx, {"playlist": playlist_name, "added_track_id": track.ID, "title": track.Title, **safety})
@playlist.command("clear")
@click.argument("name")
@click.option("--force", is_flag=True, help="Allow write while Rekordbox is running after creating a backup.")
@click.option("--no-backup", is_flag=True, help="Skip the pre-write master.db backup when Rekordbox is closed.")
@click.pass_context
def playlist_clear(ctx, name: str):
def playlist_clear(ctx, name: str, force: bool, no_backup: bool):
"""Remove all tracks from a playlist."""
db = _open_db()
pl = next((p for p in db.get_playlist() if p.Name == name), None)
if not pl:
raise click.ClickException(f"playlist {name!r} not found")
db, safety = _open_db_for_write(force=force, backup=not no_backup, operation=f"clear playlist {name!r}")
pl = next((p for p in db.get_playlist() if p.Name == name), None)
if not pl:
raise click.ClickException(f"playlist {name!r} not found")
n = 0
for sp in list(pl.Songs):
db.remove_from_playlist(pl, sp)
n += 1
_commit_force(db)
_emit(ctx, {"playlist": name, "removed": n})
_commit_db_write(db)
_emit(ctx, {"playlist": name, "removed": n, **safety})
# ---------------------------------------------------------------- deck (MIDI)
@@ -1,7 +1,7 @@
---
name: "cli-anything-rekordbox"
description: >-
Command-line interface for Pioneer Rekordbox 6/7 - DJ library and live-deck control via direct SQLCipher master.db access (pyrekordbox) + virtual MIDI mapping. Provides library inspection, playlist CRUD, cue/hot-cue management, and live-deck mixing (play/sync/crossfade/EQ). Pioneer ships no playback REST API; this harness combines the only two real surfaces (encrypted DB + MIDI) into one agent-native CLI.
Command-line interface for Pioneer Rekordbox 6/7 - DJ library and live-deck control via guarded SQLCipher master.db access (pyrekordbox) + virtual MIDI mapping. Provides library inspection, playlist creation/add/clear, and live-deck mixing (play/sync/crossfade/EQ). Pioneer ships no playback REST API; this harness combines the only two real surfaces (encrypted DB + MIDI) into one agent-native CLI.
---
# cli-anything-rekordbox
@@ -49,9 +49,13 @@ cli-anything-rekordbox --json library search "Daft Punk"
| Command | Description |
|---------|-------------|
| `list` | All playlists |
| `create NAME` | New playlist |
| `add NAME --track-title T` | Add track |
| `clear NAME` | Empty a playlist |
| `create NAME [--force] [--no-backup]` | New playlist |
| `add NAME --track-title T [--force] [--no-backup]` | Add track |
| `clear NAME [--force] [--no-backup]` | Empty a playlist |
Playlist writes create a `master.db` backup before mutation. They refuse to run
while Rekordbox is open unless `--force` is supplied; forced writes still require
a backup. `--no-backup` is only accepted when Rekordbox is closed.
### Deck (live MIDI)
| Command | Description |
@@ -1,5 +1,9 @@
"""Smoke tests — verify package imports + Click CLI parses without error."""
import subprocess, sys
"""Smoke tests — verify package imports, CLI parsing, and write guards."""
import subprocess
import sys
import click
import pytest
def test_import():
@@ -21,6 +25,63 @@ def test_subcommand_help():
assert r.returncode == 0, f"{sub} --help failed: {r.stderr}"
def test_playlist_write_options_are_documented():
r = subprocess.run([sys.executable, "-m", "cli_anything.rekordbox", "playlist", "create", "--help"],
capture_output=True, text=True)
assert r.returncode == 0, r.stderr
assert "--force" in r.stdout
assert "--no-backup" in r.stdout
def test_write_guard_refuses_running_rekordbox_without_force(monkeypatch):
import cli_anything.rekordbox.rekordbox_cli as rb
monkeypatch.setattr(rb, "_is_rekordbox_running", lambda: True)
monkeypatch.setattr(rb, "_open_db", lambda: pytest.fail("write DB should not open"))
with pytest.raises(click.ClickException, match="Refusing"):
rb._open_db_for_write(force=False, backup=True, operation="create playlist")
def test_write_guard_requires_backup_for_forced_running_write(monkeypatch):
import cli_anything.rekordbox.rekordbox_cli as rb
monkeypatch.setattr(rb, "_is_rekordbox_running", lambda: True)
monkeypatch.setattr(rb, "_open_db", lambda: pytest.fail("write DB should not open"))
with pytest.raises(click.ClickException, match="without a backup"):
rb._open_db_for_write(force=True, backup=False, operation="create playlist")
def test_write_guard_creates_backup_before_write(monkeypatch, tmp_path):
import cli_anything.rekordbox.rekordbox_cli as rb
db_path = tmp_path / "master.db"
db_path.write_bytes(b"synthetic sqlcipher bytes")
class FakeUrl:
database = str(db_path)
class FakeEngine:
url = FakeUrl()
class FakeDb:
engine = FakeEngine()
monkeypatch.setattr(rb, "_is_rekordbox_running", lambda: False)
monkeypatch.setattr(rb, "_open_db", lambda: FakeDb())
db, safety = rb._open_db_for_write(force=False, backup=True, operation="create playlist")
assert isinstance(db, FakeDb)
assert safety["rekordbox_running"] is False
assert safety["forced"] is False
assert len(safety["backup"]) == 1
backup_path = safety["backup"][0]
assert backup_path.startswith(str(tmp_path / "cli-anything-backups"))
assert open(backup_path, "rb").read() == b"synthetic sqlcipher bytes"
def test_data_file_present():
"""Bunker.midi.csv must ship with the package."""
from pathlib import Path
+8 -4
View File
@@ -1,7 +1,7 @@
---
name: "cli-anything-rekordbox"
description: >-
Command-line interface for Pioneer Rekordbox 6/7 - DJ library and live-deck control via direct SQLCipher master.db access (pyrekordbox) + virtual MIDI mapping. Provides library inspection, playlist CRUD, cue/hot-cue management, and live-deck mixing (play/sync/crossfade/EQ). Pioneer ships no playback REST API; this harness combines the only two real surfaces (encrypted DB + MIDI) into one agent-native CLI.
Command-line interface for Pioneer Rekordbox 6/7 - DJ library and live-deck control via guarded SQLCipher master.db access (pyrekordbox) + virtual MIDI mapping. Provides library inspection, playlist creation/add/clear, and live-deck mixing (play/sync/crossfade/EQ). Pioneer ships no playback REST API; this harness combines the only two real surfaces (encrypted DB + MIDI) into one agent-native CLI.
---
# cli-anything-rekordbox
@@ -49,9 +49,13 @@ cli-anything-rekordbox --json library search "Daft Punk"
| Command | Description |
|---------|-------------|
| `list` | All playlists |
| `create NAME` | New playlist |
| `add NAME --track-title T` | Add track |
| `clear NAME` | Empty a playlist |
| `create NAME [--force] [--no-backup]` | New playlist |
| `add NAME --track-title T [--force] [--no-backup]` | Add track |
| `clear NAME [--force] [--no-backup]` | Empty a playlist |
Playlist writes create a `master.db` backup before mutation. They refuse to run
while Rekordbox is open unless `--force` is supplied; forced writes still require
a backup. `--no-backup` is only accepted when Rekordbox is closed.
### Deck (live MIDI)
| Command | Description |