diff --git a/rekordbox/agent-harness/REKORDBOX.md b/rekordbox/agent-harness/REKORDBOX.md index ed9af0575..bb5017b18 100644 --- a/rekordbox/agent-harness/REKORDBOX.md +++ b/rekordbox/agent-harness/REKORDBOX.md @@ -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. diff --git a/rekordbox/agent-harness/cli_anything/__init__.py b/rekordbox/agent-harness/cli_anything/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/rekordbox/agent-harness/cli_anything/rekordbox/README.md b/rekordbox/agent-harness/cli_anything/rekordbox/README.md index de750ef6f..f6bdc41e2 100644 --- a/rekordbox/agent-harness/cli_anything/rekordbox/README.md +++ b/rekordbox/agent-harness/cli_anything/rekordbox/README.md @@ -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 diff --git a/rekordbox/agent-harness/cli_anything/rekordbox/rekordbox_cli.py b/rekordbox/agent-harness/cli_anything/rekordbox/rekordbox_cli.py index c8f1d6cbe..1c6587d27 100644 --- a/rekordbox/agent-harness/cli_anything/rekordbox/rekordbox_cli.py +++ b/rekordbox/agent-harness/cli_anything/rekordbox/rekordbox_cli.py @@ -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) diff --git a/rekordbox/agent-harness/cli_anything/rekordbox/skills/SKILL.md b/rekordbox/agent-harness/cli_anything/rekordbox/skills/SKILL.md index 49a1acca1..0c7721860 100644 --- a/rekordbox/agent-harness/cli_anything/rekordbox/skills/SKILL.md +++ b/rekordbox/agent-harness/cli_anything/rekordbox/skills/SKILL.md @@ -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 | diff --git a/rekordbox/agent-harness/cli_anything/rekordbox/tests/test_smoke.py b/rekordbox/agent-harness/cli_anything/rekordbox/tests/test_smoke.py index f682df33b..95e0eb2e6 100644 --- a/rekordbox/agent-harness/cli_anything/rekordbox/tests/test_smoke.py +++ b/rekordbox/agent-harness/cli_anything/rekordbox/tests/test_smoke.py @@ -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 diff --git a/skills/cli-anything-rekordbox/SKILL.md b/skills/cli-anything-rekordbox/SKILL.md index 49a1acca1..0c7721860 100644 --- a/skills/cli-anything-rekordbox/SKILL.md +++ b/skills/cli-anything-rekordbox/SKILL.md @@ -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 |