mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-30 18:12:56 +08:00
fix: address harness review blockers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user