diff --git a/.env.example b/.env.example index 94bd160..8c50df7 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,15 @@ PLEX_PLAYLIST_FLATTENING_DELIMITER="/" #MUSIC_ROOT="/tank/music" #TAG_BACKUP_DIR="/path/to/lossless-tag-backups" # default: ./lossless-tag-backups +# Rekordbox "Date Added" preservation (`rb-dates` subcommand). When a lossy file is +# re-added as lossless, Rekordbox resets its Date Added; rb-dates snapshots the old +# date (keyed by the lossless path) and restores it onto the re-added row. This is the +# ONLY command that writes to master.db — it backs the DB up first and is guarded +# (Rekordbox must be closed everywhere + Resilio idle). Reuses REKORDBOX_MASTERDB_PATH +# and REKORDBOX_ADDED_AT_FIELD above. +#RB_DATE_SNAPSHOT_PATH="/path/to/rb-date-snapshots.json" # default: ./rb-date-snapshots.json +#RB_DATE_BACKUP_DIR="/path/to/rekordbox-db-backups" # default: ./rekordbox-db-backups + # Artist images (`artist-images` subcommand). Sets Plex ARTIST POSTERS from external # sources for a local-metadata library (the one sanctioned artwork-via-API exception). # Each artist's MBID comes from Plex's own agent match (falling back to MusicBrainz), diff --git a/.gitignore b/.gitignore index 1b3dee5..e510a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ parity-*.tsv # Artwork backups / scratch output aiff-title-backups/ +lossless-tag-backups/ + +# rb-dates: captured-date sidecar + Rekordbox DB backups (never commit these) +rb-date-snapshots.json +rekordbox-db-backups/ diff --git a/CLAUDE.md b/CLAUDE.md index a3ce017..65da251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Scope -This tool does seven things, exposed as subcommands: +This tool does eight things, exposed as subcommands: 1. **`playlists`** — mirrors Rekordbox playlists into Plex. Audio file tags are the source of truth for track/album metadata — Plex picks them up on its own scan. Do **not** add code that pushes track/album metadata, artwork, field locks, or library-scan triggers **via the Plex API**; that path was deliberately removed. (The **sanctioned exceptions** are *artist-poster upload* — subcommand 5 — and *poster clearing via a direct DB write* — subcommand 6 — because artist images cannot come from audio file tags. Neither loosens the rule for track/album metadata or art from tags.) 2. **`dates`** — syncs "Date Added" (`metadata_items.added_at`) from Rekordbox `djmdContent.created_at` by a **direct write to the Plex SQLite DB** (the Plex HTTP API has no `added_at` setter). This is a *sanctioned* DB write and is distinct from the removed API metadata-push path. It **reads the Plex library straight from the DB** (`plex/PlexDBReader.py::read_library`, not the API) and computes changes **in memory — no SQL file is written** (the write streams SQL to Plex SQLite over stdin; `--plan-file` optionally dumps it). **Read-only by default** (`--dry-run`); writing requires `--write`, Plex stopped, and the `WRITE-DATES` token. Idempotent (only rows whose date differs). See `actions/DateAddedRestore.py`, `plex/PlexDBReader.py`, `plex/PlexDBWriter.py`. @@ -12,7 +12,8 @@ This tool does seven things, exposed as subcommands: 4. **`aiff-titles`** — repairs AIFF and AIFF-C (`AIFC`) files whose **native AIFF `NAME` chunk** (which Plex reads for the title) shadows the correct **ID3 `TIT2`** (which Rekordbox/OneTagger use). This is a **direct edit of the audio FILE on disk** — distinct from the removed Plex-API metadata-push path: it never touches the Plex DB or API to set metadata, it fixes the source file so Plex picks the tag up on its own (optionally via album-level Refresh through `--refresh-plex`). Enumerates Plex AIFF/AIFF-C tracks read-only from the DB (`read_tracks_metadata`), reads each file's `NAME` chunk + ID3 `TIT2`, and on `NAME != TIT2` shows a diff table. **Read-only by default** (`--dry-run`); writing requires `--write` and the `WRITE-TITLES` token, backs up every original, and **leaves the audio (`SSND`) and ID3 chunk byte-identical** (asserted per file). Both `FORM` types (`AIFF`/`AIFC`) are handled — identical after the 12-byte header. Unlike `dates`/`parity` it must open files on disk, so it needs `PLEX_MEDIA_PATH_MAP` to translate Plex *container* paths → host paths. See `actions/AiffTitleFix.py`, `utils/aiff_chunks.py`, `utils/media_paths.py`. 5. **`artist-images`** — sets Plex **artist posters** for a library on the local-metadata agent that has no artist art. This is the **sanctioned artwork-via-API exception** (artist posters *only* — never track/album art). It does **not** rebind the library to the online agent: each artist's identity (**MBID + canonical name**) comes from Plex's own `Artist.matches()` (read-only candidate search, no `fixMatch`) with a **MusicBrainz** text-search fallback; the portrait is then fetched from providers in order — **fanart.tv → TheAudioDB → Deezer → Spotify → Bandcamp → Discogs** (curated MBID portraits first; streaming for modern/electronic; Discogs for breadth). Name-based hits are **verified** against the local tag or Plex's canonical name (no wrong-artist matches). The action **centrally rejects unusable images** — known placeholders (per-provider md5 fingerprint, e.g. Deezer's grey silhouette) and blank/single-color images (Pillow near-uniform) — uniformly across every provider (real portraits are size-gated so they're never downloaded). `--collab-mode` handles multi-artist "A, B" strings: `primary` (first member's portrait) or `collage` (Pillow composite of each member, built client-side — Plex has no native collage). Splitting is **last-resort** (the full string is tried across every source first); comma/`feat.` split by default, with opt-in ambiguous `&`/`+` separators (`--collab-extra-seps` / `ARTIST_COLLAB_EXTRA_SEPARATORS`) that only split a name/component which missed every source *whole* — so `Above & Beyond` (resolves whole) stays intact while `Bendik Baksaas + Fredrik Høyer` becomes a collage. Split pieces use a stricter MB score (`ARTIST_COLLAB_MIN_SCORE`, default 95) and a minimum segment length (`ARTIST_COLLAB_MIN_SEGMENT_LEN`, default 2). Concurrent (`--threads`, default 8). **Read-only by default**; `--write` uploads behind the `WRITE-IMAGES` token. See `actions/ArtistImageSync.py`, `artwork/` (`mbid.py`, `musicbrainz.py`, `registry.py`, `providers/`, `placeholder.py`, `collab.py`, `collage.py`). 6. **`lossless-tags`** — ports tags onto a lossless replacement of a lossy file. The user is swapping leftover lossy files (MP3) for lossless ones (AIFF) over time; when a fresh AIFF is dropped next to the old MP3 (**same basename, same directory**), this copies the **entire ID3 tag wholesale** (all frames incl. APIC artwork) from the lossy file onto the lossless one so curated metadata survives the swap. Like `aiff-titles` it's a **direct edit of the audio FILE on disk** (never the Plex API/DB for metadata) — distinct from the removed metadata-push path. Discovery is a **filesystem walk** of `MUSIC_ROOT`/`--root` (no Plex/Rekordbox lookup), pairing per-directory by basename. The copy uses mutagen (`AIFF.save()` surgically rewrites only the `ID3 ` chunk, leaving `SSND` audio + the native `NAME` chunk intact); after the copy it aligns the AIFF `NAME` chunk to the new title (reusing `utils/aiff_chunks.py::rewrite_name`, or strips it with `--remove-name`) so Plex shows the right title. **Read-only by default** (prints the per-pair tag diff — the parity/dry-run); `--write` overwrites the lossless ID3 behind the `WRITE-TITLES`-style `WRITE-TAGS` token, **backing up every lossless original first**. Only AIFF/AIFF-C targets are supported (FLAC/WAV skipped); ambiguous (>1 sibling), untagged-source, and zero-byte/broken-symlink pairings are skipped + reported. `--show matched|unmatched|both` lists lossy files with and/or without a lossless match (the upgrade backlog). `--delete-lossy` removes the source only after a fully successful copy; `--refresh-plex` queues a partial Plex scan of the affected dirs (needs `PLEX_MEDIA_PATH_MAP` + the Plex API). See `actions/LosslessTagCopy.py`, `utils/id3_tags.py`, `utils/aiff_chunks.py`, `utils/media_paths.py::resolve_container_path`. -7. **`clear-art`** — removes uploaded **artist/album posters**: clears `metadata_items.user_thumb_url` (what selects the poster) via a **direct, guarded Plex DB write** — the only way to delete an uploaded poster, since the HTTP API can't — **and** deletes the on-disk image file from the item's metadata bundle (`--keep-files` to skip the file delete). Reuses the `dates` write mechanism (bundled "Plex SQLite" via `docker run` as the DB-file owner). `--kind artist|album|both`, `--only `. **Read-only by default** (lists what would change); `--write` requires Plex stopped + the `CLEAR-IMAGES` token. Only targets `upload://` posters (tool/manually-set), never embedded APIC art. Use it to reset before repopulating with `artist-images`. See `actions/ArtworkClear.py`, `plex/poster_files.py`, `plex/PlexDBReader.py` (`read_upload_posters`), `plex/PlexDBWriter.py` (`build_clear_posters_sql`). +7. **`rb-dates`** — preserves the Rekordbox **"Date Added"** (`djmdContent.created_at`) across a lossy→lossless file swap. Replacing a file forces a **re-add** in Rekordbox (re-analyze won't do for a format change), which resets `created_at` to now. This two-phase, path-keyed flow rescues it: **`snapshot`** (read-only) resolves each lossy file to its Rekordbox row, reads the raw `created_at`, and stores it in a sidecar JSON keyed by the **lossless** file's path (`actions/RekordboxDateCarry.py`, `rekordbox/date_snapshot.py`, `utils/snapshot_store.py`); **`apply`** re-resolves the re-added lossless row by path and writes the date back. This is the **first and only *sanctioned* write to the Rekordbox DB** — every other Rekordbox access is strictly read-only (`RekordboxDB.py` opens a temp copy `mode=ro`). The write (`rekordbox/RekordboxDBWriter.py`) opens the **real** `master.db` read-write, is **USN-correct** (bumps the row's `rb_local_usn` + the global `agentRegistry.localUpdateCount`, exactly as Rekordbox does for a local edit), and `wal_checkpoint(TRUNCATE)`s so the change lands in one consistent file (the DB is **Resilio-synced** across workstations). Because of that, the write is **heavily guarded** (`utils/rb_db_safety.py`): **read-only/dry-run by default**, and `--write` requires the `WRITE-RB-DATES` token, a typed "free up the DB" checklist (close Rekordbox on **every** workstation, pause Resilio), a non-empty-WAL refusal, a file-stability probe, a local `lsof` check, and a mandatory `master.db` backup. The path identity is bridged host→container→Rekordbox via `resolve_rb_id_by_host_path` (`rekordbox/resolvers/track.py`). Also available as a `lossless-tags --snapshot-dates` hook so the date is captured in the same pass as the tag copy, before any `--delete-lossy`. See `actions/RekordboxDateCarry.py`, `rekordbox/RekordboxDBWriter.py`, `utils/rb_db_safety.py`, `utils/snapshot_store.py`. +8. **`clear-art`** — removes uploaded **artist/album posters**: clears `metadata_items.user_thumb_url` (what selects the poster) via a **direct, guarded Plex DB write** — the only way to delete an uploaded poster, since the HTTP API can't — **and** deletes the on-disk image file from the item's metadata bundle (`--keep-files` to skip the file delete). Reuses the `dates` write mechanism (bundled "Plex SQLite" via `docker run` as the DB-file owner). `--kind artist|album|both`, `--only `. **Read-only by default** (lists what would change); `--write` requires Plex stopped + the `CLEAR-IMAGES` token. Only targets `upload://` posters (tool/manually-set), never embedded APIC art. Use it to reset before repopulating with `artist-images`. See `actions/ArtworkClear.py`, `plex/poster_files.py`, `plex/PlexDBReader.py` (`read_upload_posters`), `plex/PlexDBWriter.py` (`build_clear_posters_sql`). ## Commands @@ -22,6 +23,7 @@ This tool does seven things, exposed as subcommands: - Metadata parity audit (read-only): `poetry run rekordbox2plex parity` (flags: `--fields title,artist,album,albumartist` — default `title,artist,album`, albumartist opt-in; `--split-artists` + `--split-artists-ordered` + `--collab-extra-seps "& +"` for set-based artist/albumartist comparison; `--only `, `--no-orphans`, `--orphan-limit `, `--json`; env `REKORDBOX_FOLDER_PATHS_TO_IGNORE`) - Fix AIFF `NAME`-chunk titles (read-only by default): `poetry run rekordbox2plex aiff-titles --dry-run` (flags: `--write`, `--remove-name`, `--refresh-plex`, `--only `, `--backup-dir `; env `PLEX_MEDIA_PATH_MAP` required, `AIFF_BACKUP_DIR` optional). Write requires the `WRITE-TITLES` token; backs up originals; audio + ID3 left untouched. - Copy tags onto lossless replacements (read-only by default): `poetry run rekordbox2plex lossless-tags --root --dry-run` (flags: `--write`, `--show matched|unmatched|both`, `--lossy-exts`, `--lossless-exts`, `--remove-name`, `--delete-lossy`, `--refresh-plex`, `--mirror-version`, `--ignore-case`, `--limit `, `--backup-dir `; env `MUSIC_ROOT`, `TAG_BACKUP_DIR`, and `PLEX_MEDIA_PATH_MAP` only for `--refresh-plex`). Write requires the `WRITE-TAGS` token; backs up each lossless original; audio (`SSND`) left byte-identical. +- Preserve Rekordbox "Date Added" across a file swap (two phases): `poetry run rekordbox2plex rb-dates snapshot --root ` (read-only; captures `created_at` into the sidecar) then, after re-adding the lossless file in Rekordbox, `poetry run rekordbox2plex rb-dates apply --dry-run` → `--write` (flags: `--snapshot-file`, `--backup-dir`, `--ignore-wal`, `--stability-wait `, `--keep-applied`, `--allow-running`; env `RB_DATE_SNAPSHOT_PATH`, `RB_DATE_BACKUP_DIR`, reuses `REKORDBOX_MASTERDB_PATH`/`REKORDBOX_ADDED_AT_FIELD`). Write requires the `WRITE-RB-DATES` token, Rekordbox closed everywhere + Resilio idle (guarded), and backs up `master.db` first. Or capture during the tag copy with `lossless-tags --snapshot-dates`. - Set artist posters (read-only by default): `poetry run rekordbox2plex artist-images --dry-run` (flags: `--write`, `--overwrite`, `--collab-mode skip|primary|collage`, `--providers `, `--only `, `--limit `, `--threads `; env `PLEX_ARTIST_IMAGE_PROVIDERS`, `DISCOGS_TOKEN`/`DISCOGS_KEY`+`DISCOGS_SECRET`, `FANARTTV_API_KEY`, `SPOTIFY_CLIENT_ID`/`SPOTIFY_CLIENT_SECRET`, `THEAUDIODB_API_KEY`, `MUSICBRAINZ_USER_AGENT`). Write requires the `WRITE-IMAGES` token. - Clear uploaded artist/album posters (read-only by default; Plex must be stopped to write): `poetry run rekordbox2plex clear-art --dry-run` (flags: `--write`, `--kind artist|album|both`, `--only `, `--keep-files`, `--allow-running`; env `PLEX_METADATA_PATH` optional, else derived from `PLEX_DB_PATH`). Write requires the `CLEAR-IMAGES` token; clears the DB poster selection and deletes the on-disk file. - Tests: `poetry run pytest` — single test: `poetry run pytest tests/TrackIdMapper_test.py::test_track_mapper` @@ -41,7 +43,7 @@ The flow is **Plex-driven**: walk every Plex track once to build an in-memory `P - `mappers/TrackIdMapper.py` — singleton dict `rb_track_id → PlexTrackWrapper`. `ensure_mappings()` lazily walks every Plex track and resolves its Rekordbox ID via `rekordbox/resolvers/track.py::resolve_track_id` (single-column lookup by file path). - `plex/repositories/` — cached accessors over `plexapi`. Extend `_RepositoryBase.RepositoryBase`, wrapped in the local `singleton` decorator. - `plex/resolvers/` and `rekordbox/resolvers/` — thin functional wrappers around the SDKs / SQL queries. -- `rekordbox/RekordboxDB.py` — singleton SQLCipher connection. By default (`REKORDBOX_COPY_DB_BEFORE_SYNC=true`) it copies `master.db` to a tempfile and opens it read-only (`mode=ro`) before applying `PRAGMA key`. The temp copy is deleted via `atexit`. Always treat the Rekordbox DB as read-only. +- `rekordbox/RekordboxDB.py` — singleton SQLCipher connection. By default (`REKORDBOX_COPY_DB_BEFORE_SYNC=true`) it copies `master.db` to a tempfile and opens it read-only (`mode=ro`) before applying `PRAGMA key`. The temp copy is deleted via `atexit`. Treat the Rekordbox DB as read-only **everywhere except** `rekordbox/RekordboxDBWriter.py` — the single sanctioned write path (the `rb-dates apply` date restore), which opens the real `master.db` read-write behind the `utils/rb_db_safety.py` guards. Do not add other Rekordbox writes. - `config.py` — central env / CLI accessor. CLI args are stashed via `set_args(...)` in `__main__.py` and read back through `is_dry_run()`, `should_wipe()`, etc. ### Key cross-cutting flows diff --git a/README.md b/README.md index 69acde8..1a8034f 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,10 @@ cp .env.example .env | `REKORDBOX_TZ` | string | host local | (`dates` only) Timezone for interpreting *naive* Rekordbox timestamps. Ignored for offset-aware ones like `created_at`. | | `PLEX_MEDIA_PATH_MAP` | string | – | (`aiff-titles`; `lossless-tags --refresh-plex`) **Required for `aiff-titles`.** Comma-separated `container=host` path-prefix pairs mapping Plex's stored file paths to host paths so the audio files can be opened (e.g. `/data/music=/tank/music`). Longest prefix wins. | | `AIFF_BACKUP_DIR` | string | `./aiff-title-backups` | (`aiff-titles` only) Where originals are backed up before `--write` edits them. | -| `MUSIC_ROOT` | string | – | (`lossless-tags` only) Filesystem root walked for lossy/lossless pairs (or pass `--root`). | +| `MUSIC_ROOT` | string | – | (`lossless-tags`, `rb-dates`) Filesystem root walked for lossy/lossless pairs (or pass `--root`). | | `TAG_BACKUP_DIR` | string | `./lossless-tag-backups` | (`lossless-tags` only) Where lossless originals are backed up before `--write` overwrites their ID3. | +| `RB_DATE_SNAPSHOT_PATH` | string | `./rb-date-snapshots.json` | (`rb-dates`) Sidecar JSON holding captured Rekordbox dates (or pass `--snapshot-file`). | +| `RB_DATE_BACKUP_DIR` | string | `./rekordbox-db-backups` | (`rb-dates apply` only) Where `master.db` is backed up before a `--write` (or pass `--backup-dir`). | | `LOGGER_NAME` | string | `rekordbox2plex` | Logger name used for log output. | > 🔐 **How to find your Plex Token?** See [this guide](#how-to-find-your-plex-api-token). @@ -164,7 +166,7 @@ Map each Plex path to the corresponding Rekordbox path: ## Usage -The tool has seven subcommands — **`playlists`**, **`dates`**, **`parity`**, **`aiff-titles`**, **`lossless-tags`**, **`artist-images`**, and **`clear-art`** — which you can run independently: +The tool has eight subcommands — **`playlists`**, **`dates`**, **`parity`**, **`aiff-titles`**, **`lossless-tags`**, **`rb-dates`**, **`artist-images`**, and **`clear-art`** — which you can run independently: ```bash poetry run rekordbox2plex playlists # mirror Rekordbox playlists into Plex @@ -175,6 +177,8 @@ poetry run rekordbox2plex aiff-titles --dry-run # preview AIFF NAME-chunk titl poetry run rekordbox2plex aiff-titles --write # repair them (file edits; see below) poetry run rekordbox2plex lossless-tags --root /tank/music --dry-run # preview lossy→lossless tag copies (read-only) poetry run rekordbox2plex lossless-tags --root /tank/music --write # copy the tags onto the lossless files (see below) +poetry run rekordbox2plex rb-dates snapshot --root /tank/music # capture Rekordbox "Date Added" before a swap (read-only) +poetry run rekordbox2plex rb-dates apply --dry-run # preview restoring those dates (read-only) poetry run rekordbox2plex artist-images --dry-run # preview artist posters to set (read-only) poetry run rekordbox2plex artist-images --write # upload them to Plex (see below) poetry run rekordbox2plex clear-art --dry-run # preview uploaded posters to remove (read-only) @@ -186,6 +190,7 @@ poetry run rekordbox2plex clear-art --write # remove them (Plex must be st - **`parity`** — read-only metadata audit; see [Checking parity](#checking-parity). - **`aiff-titles`** — fix AIFF titles where the legacy `NAME` chunk shadows ID3; see [Fixing AIFF titles](#fixing-aiff-titles). - **`lossless-tags`** — copy tags from a lossy file onto a same-named lossless replacement; see [Porting tags to lossless files](#porting-tags-to-lossless-files). +- **`rb-dates`** — preserve the Rekordbox "Date Added" across a lossy→lossless swap; see [Preserving Rekordbox "Date Added"](#preserving-rekordbox-date-added). - **`artist-images`** — set artist posters from external sources; see [Artist images](#artist-images). - **`clear-art`** — remove uploaded artist/album posters; see [Clearing artwork](#clearing-artwork). @@ -348,6 +353,63 @@ Use `--show unmatched` (or `both`) to list the lossy files that **don't** yet ha --- +## Preserving Rekordbox "Date Added" + +When you replace a lossy file with a lossless one, Rekordbox can't just re-analyze it — a format change means the track has to be **re-added**, which creates a fresh entry with **"Date Added" = now**, losing the original. `rb-dates` rescues that date. It's a **two-phase, path-keyed** flow: capture the old date *before* the swap, restore it *after* you re-add the file. + +> ⚠️ **This is the only command that writes to the Rekordbox database.** Every other Rekordbox access is strictly read-only. The write is USN-correct (it bumps Rekordbox's own update counters exactly as a normal edit does) and heavily guarded, but you **must free up the DB first** — see the runbook below. + +### 1. Snapshot (read-only, safe) + +Before deleting the lossy file, capture its Rekordbox date: + +```bash +poetry run rekordbox2plex rb-dates snapshot --root /tank/music # save to the sidecar +poetry run rekordbox2plex rb-dates snapshot --root /tank/music --dry-run # preview only +``` + +This walks the root, pairs each lossy file with its same-named lossless sibling (same logic as `lossless-tags`), resolves the **lossy** file to its Rekordbox row, and stores its raw `created_at` in a sidecar JSON keyed by the **lossless** file's path (`./rb-date-snapshots.json`, or `RB_DATE_SNAPSHOT_PATH`/`--snapshot-file`). The raw string is stored verbatim so it restores byte-for-byte. + +You can also capture in the **same pass as the tag copy** — `lossless-tags --write --snapshot-dates` snapshots each pair's date before it (optionally) deletes the MP3, so you can't forget. + +### 2. Re-add the file in Rekordbox + +Delete the lossy file and add the lossless one in Rekordbox as you normally would. Its "Date Added" will (wrongly) be now — that's what we fix next. + +### 3. Apply — restore the dates (writes Rekordbox) + +**Read-only by default** — preview the `now → original` change for every re-added file: + +```bash +poetry run rekordbox2plex rb-dates apply --dry-run +``` + +Files you haven't re-added yet are listed as "awaiting re-add" and kept in the sidecar for later. When the preview looks right, run the guarded write: + +```bash +poetry run rekordbox2plex rb-dates apply --write # type WRITE-RB-DATES to confirm +``` + +#### ⚠️ Runbook — free up the DB before `--write` + +`master.db` is synced across your machines (Resilio) and Rekordbox may hold it open anywhere. The write refuses to run unless the DB looks free, but the human steps are on you: + +1. **Quit Rekordbox on _every_ workstation.** A clean quit also checkpoints the WAL. +2. **Pause Resilio Sync** (or be sure it's idle) so it doesn't propagate a half-written file or spawn conflict copies. +3. Run `apply --write`. It will, in order: show the checklist + require the `WRITE-RB-DATES` token → refuse if a non-empty `master.db-wal` exists (`--ignore-wal` to override) → probe that the file isn't changing (`--stability-wait`, default 5s) → check nothing local holds it open → **back up `master.db`** (to `./rekordbox-db-backups`, or `RB_DATE_BACKUP_DIR`) → write the dates and checkpoint the WAL into the main file. +4. **Keep Rekordbox closed everywhere until Resilio has propagated** the new `master.db` to all nodes, then verify the dates and resume. + +Successfully-restored entries are pruned from the sidecar (so the backlog shrinks and re-runs are idempotent); pass `--keep-applied` to retain them. + +Once the Rekordbox date is correct, the existing [`dates`](#syncing-date-added) command carries it through to Plex's "Date Added". + +### `rb-dates` arguments + +* **`snapshot`** — `--root ` (or `MUSIC_ROOT`), `--dry-run`, `--snapshot-file `, `--lossy-exts`/`--lossless-exts`, `--ignore-case`. +* **`apply`** — `--dry-run` (default) / `--write` (needs the `WRITE-RB-DATES` token), `--snapshot-file `, `--backup-dir `, `--ignore-wal`, `--stability-wait `, `--keep-applied`, `--allow-running` (bypass the guards — scratch-copy testing only). + +--- + ## Artist images If you run a Plex music library on the **local-metadata agent** (so it doesn't fetch from Plex's online agent), your artists have **no artist art**. `rekordbox2plex artist-images` sets **artist posters** from external sources — this is the **one sanctioned artwork-via-API exception** (artist posters *only*; it never touches track/album art, which comes from your embedded tags). diff --git a/src/rekordbox2plex/__main__.py b/src/rekordbox2plex/__main__.py index f4d9ab7..b116afc 100644 --- a/src/rekordbox2plex/__main__.py +++ b/src/rekordbox2plex/__main__.py @@ -8,6 +8,7 @@ from .actions.ParityCheck import ParityCheck from .actions.AiffTitleFix import AiffTitleFix from .actions.LosslessTagCopy import LosslessTagCopy +from .actions.RekordboxDateCarry import RekordboxDateCarry from .actions.ArtistImageSync import ArtistImageSync from .actions.ArtworkClear import ArtworkClear from .utils.confirm import confirm_destructive @@ -46,6 +47,11 @@ def main(): logger.info("[bold green]✔ Lossless tag copy finished!") return + if config.get_command() == "rb-dates": + RekordboxDateCarry().run() + logger.info("[bold green]✔ Rekordbox date carry finished!") + return + if config.get_command() == "artist-images": ArtistImageSync().run() logger.info("[bold green]✔ Artist image sync finished!") diff --git a/src/rekordbox2plex/actions/LosslessTagCopy.py b/src/rekordbox2plex/actions/LosslessTagCopy.py index 412e997..b5bb230 100644 --- a/src/rekordbox2plex/actions/LosslessTagCopy.py +++ b/src/rekordbox2plex/actions/LosslessTagCopy.py @@ -11,6 +11,7 @@ get_lossy_exts, get_music_root, get_plex_media_path_map, + get_rb_date_snapshot_path, get_show_mode, get_tag_backup_dir, get_tag_copy_limit, @@ -19,13 +20,17 @@ should_mirror_id3_version, should_refresh_plex, should_remove_name, + should_snapshot_dates, should_write, ) +from ..rekordbox.date_snapshot import capture_dates +from ..utils import snapshot_store from ..utils.aiff_chunks import rewrite_name from ..utils.confirm import confirm_destructive from ..utils.id3_tags import copy_id3_wholesale, read_id3_fields from ..utils.logger import console, logger from ..utils.media_paths import parse_media_path_map, resolve_container_path +from ..utils.pairing import walk_pairs from ..utils.paths import PROJECT_ROOT from ..utils.progress_bar import progress_instance @@ -72,6 +77,8 @@ def __init__(self) -> None: self.mirror_version = should_mirror_id3_version() self.show = get_show_mode() self.limit = get_tag_copy_limit() + self.snapshot_dates = should_snapshot_dates() + self.snapshot_file = get_rb_date_snapshot_path() self.path_map = parse_media_path_map(get_plex_media_path_map()) def run(self) -> None: @@ -90,13 +97,6 @@ def run(self) -> None: # --- pairing ----------------------------------------------------------- - def _classify(self, ext: str) -> Optional[str]: - if ext in self.lossy_exts: - return "lossy" - if ext in self.lossless_exts: - return "lossless" - return None - def _usable(self, path: str) -> Optional[str]: """Return None if the file is usable, else a skip reason. Guards broken symlinks and zero-byte files (both ends of a pair go through this).""" @@ -119,66 +119,43 @@ def compute( unmatched: lossy files with no lossless sibling (not yet upgraded). skipped: (lossy_path, reason) for ambiguous / unusable pairings.""" assert self.root is not None # guaranteed by run() + raw_pairs, unmatched, skipped = walk_pairs( + self.root, self.lossy_exts, self.lossless_exts, self.ignore_case + ) pairs: List[Dict] = [] - unmatched: List[str] = [] - skipped: List[Tuple[str, str]] = [] - - for dirpath, _dirnames, filenames in os.walk(self.root): - # Group this directory's files by basename stem (extension stripped). - groups: Dict[str, Dict[str, List[str]]] = {} - for fn in filenames: - stem, ext = os.path.splitext(fn) - kind = self._classify(ext.lower()) - if kind is None: - continue - key = stem.lower() if self.ignore_case else stem - full = os.path.join(dirpath, fn) - groups.setdefault(key, {"lossy": [], "lossless": []})[kind].append(full) - - for grp in groups.values(): - for lossy in grp["lossy"]: - losslesses = grp["lossless"] - if not losslesses: - unmatched.append(lossy) - continue - if len(losslesses) > 1: - names = ", ".join(os.path.basename(p) for p in losslesses) - skipped.append((lossy, f">1 lossless sibling ({names})")) - continue - lossless = losslesses[0] - ext = os.path.splitext(lossless)[1].lower() - if ext not in AIFF_EXTS: - skipped.append( - (lossy, f"unsupported lossless target '{ext}' (AIFF only)") - ) - continue - for p in (lossy, lossless): - reason = self._usable(p) - if reason: - skipped.append((lossy, f"{os.path.basename(p)}: {reason}")) - break - else: - lossy_fields = read_id3_fields(lossy) - if not lossy_fields: - skipped.append((lossy, "no ID3 tag in lossy source")) - continue - lossless_fields = read_id3_fields(lossless) - pairs.append( - { - "lossy": lossy, - "lossless": lossless, - "lossy_fields": lossy_fields, - "lossless_fields": lossless_fields, - # A lossless file that already carries tags will be - # overwritten — surface that in the preview. - "overwrite": any( - lossless_fields.get(k) for k, _ in _DIFF_COLS - ), - } - ) - - pairs.sort(key=lambda d: d["lossless"].lower()) - unmatched.sort(key=str.lower) + + for lossy, lossless in raw_pairs: + ext = os.path.splitext(lossless)[1].lower() + if ext not in AIFF_EXTS: + skipped.append( + (lossy, f"unsupported lossless target '{ext}' (AIFF only)") + ) + continue + unusable = next( + ((p, r) for p in (lossy, lossless) if (r := self._usable(p))), + None, + ) + if unusable is not None: + p, reason = unusable + skipped.append((lossy, f"{os.path.basename(p)}: {reason}")) + continue + lossy_fields = read_id3_fields(lossy) + if not lossy_fields: + skipped.append((lossy, "no ID3 tag in lossy source")) + continue + lossless_fields = read_id3_fields(lossless) + pairs.append( + { + "lossy": lossy, + "lossless": lossless, + "lossy_fields": lossy_fields, + "lossless_fields": lossless_fields, + # A lossless file that already carries tags will be + # overwritten — surface that in the preview. + "overwrite": any(lossless_fields.get(k) for k, _ in _DIFF_COLS), + } + ) + skipped.sort(key=lambda t: t[0].lower()) if self.limit is not None: pairs = pairs[: self.limit] @@ -300,6 +277,12 @@ def apply(self) -> None: logger.info("[yellow]Aborted — confirmation token did not match.") return + # Capture the Rekordbox Date Added BEFORE the copy loop, so a later + # --delete-lossy can't remove the MP3 (and its filesystem pairing) before + # we've snapshotted its date keyed by the lossless path. + if self.snapshot_dates: + self._snapshot_dates(pairs) + ok = 0 deleted = 0 changed_dirs: Set[str] = set() @@ -331,6 +314,24 @@ def apply(self) -> None: if ok: self._after_write(changed_dirs) + def _snapshot_dates(self, pairs: List[Dict]) -> None: + """Capture each pair's Rekordbox Date Added into the rb-dates sidecar + (read-only RB access), keyed by the lossless path, for later restore.""" + entries, skipped = capture_dates( + [(p["lossy"], p["lossless"]) for p in pairs], self.path_map + ) + if entries: + store = snapshot_store.load(self.snapshot_file) + for k, v in entries.items(): + snapshot_store.merge_entry(store, k, v) + snapshot_store.save(self.snapshot_file, store) + console.print( + f"[dim]Snapshotted {len(entries)} Rekordbox date(s) → " + f"{self.snapshot_file}" + + (f"; {len(skipped)} not in Rekordbox" if skipped else "") + + "[/dim]" + ) + def _copy_pair(self, pair: Dict, backup_dir: str) -> None: """Back up the pristine lossless original, copy the lossy ID3 onto it, align the NAME chunk, then (optionally) delete the lossy source. The MP3 diff --git a/src/rekordbox2plex/actions/RekordboxDateCarry.py b/src/rekordbox2plex/actions/RekordboxDateCarry.py new file mode 100644 index 0000000..eb0e999 --- /dev/null +++ b/src/rekordbox2plex/actions/RekordboxDateCarry.py @@ -0,0 +1,291 @@ +import os +from typing import Dict, List, Optional, Tuple + +from rich.panel import Panel +from rich.table import Table + +from ._ActionBase import ActionBase +from ..config import ( + get_db_path, + get_lossless_exts, + get_lossy_exts, + get_music_root, + get_plex_media_path_map, + get_rb_added_at_field, + get_rb_date_backup_dir, + get_rb_date_snapshot_path, + get_rb_dates_mode, + get_stability_wait, + should_allow_running, + should_ignore_case, + should_ignore_wal, + should_keep_applied, + should_write, +) +from ..rekordbox.date_snapshot import capture_dates +from ..rekordbox.RekordboxDBWriter import RekordboxDBWriter +from ..rekordbox.resolvers.added_at import read_rb_added_at_raw +from ..rekordbox.resolvers.track import resolve_rb_id_by_host_path +from ..utils import snapshot_store +from ..utils.snapshot_store import SnapshotEntry, SnapshotStore +from ..utils.confirm import confirm_destructive +from ..utils.logger import console, logger +from ..utils.media_paths import parse_media_path_map +from ..utils.pairing import walk_pairs +from ..utils.paths import PROJECT_ROOT +from ..utils.rb_db_safety import ( + DBNotQuiescent, + assert_db_quiescent, + backup_rekordbox_db, +) + + +def _fmt(value: object) -> str: + if value is None or value == "": + return "[dim]—[/dim]" + return str(value) + + +class RekordboxDateCarry(ActionBase): + """Preserve the Rekordbox "Date Added" across a lossy→lossless file swap. + + ``snapshot`` (read-only) records each lossy file's ``created_at`` keyed by the + lossless path. ``apply`` (read-only unless ``--write``) restores it onto the + re-added lossless row — the sanctioned, heavily-guarded Rekordbox write.""" + + def __init__(self) -> None: + super().__init__("rekordbox date carry") + self.mode = get_rb_dates_mode() + self.root = get_music_root() + self.lossy_exts = get_lossy_exts() + self.lossless_exts = get_lossless_exts() + self.ignore_case = should_ignore_case() + self.field = get_rb_added_at_field() + self.snapshot_file = get_rb_date_snapshot_path() + self.path_map = parse_media_path_map(get_plex_media_path_map()) + + def run(self) -> None: + if self.mode == "snapshot": + self.snapshot() + elif self.mode == "apply": + self.apply() + else: # pragma: no cover - argparse constrains the choices + logger.error(f"[red]Unknown rb-dates mode: {self.mode!r}") + + # --- snapshot ---------------------------------------------------------- + + def snapshot(self) -> None: + if not self.root or not os.path.isdir(self.root): + logger.error( + "[red]snapshot needs a valid music root — pass --root or set " + "MUSIC_ROOT." + ) + return + pairs, _unmatched, _ambiguous = walk_pairs( + self.root, self.lossy_exts, self.lossless_exts, self.ignore_case + ) + entries, skipped = capture_dates(pairs, self.path_map, self.field) + + console.print( + f"[bold cyan]{'Dry run — ' if self.dry_run else ''}Snapshot[/bold cyan] " + f"root={self.root} field={self.field} " + f"→ {self.snapshot_file}\n" + ) + self._print_capture_table(entries) + self._print_skipped(skipped, noun="lossy file") + + if not entries: + console.print("[bold green]✔ Nothing to snapshot.") + return + if self.dry_run: + console.print( + f"\n[dim]Dry run — {len(entries)} date(s) NOT written to " + f"{self.snapshot_file}. Re-run without --dry-run to save.[/dim]" + ) + return + store = snapshot_store.load(self.snapshot_file) + changed = sum( + snapshot_store.merge_entry(store, k, v) for k, v in entries.items() + ) + snapshot_store.save(self.snapshot_file, store) + console.print( + f"\n[bold green]✔ Saved {len(entries)} snapshot(s) " + f"({changed} new/changed) → {self.snapshot_file}[/bold green]" + ) + + def _print_capture_table(self, entries: Dict[str, SnapshotEntry]) -> None: + if not entries: + return + table = Table(title="Rekordbox dates captured (keyed by lossless file)") + table.add_column("#", justify="right", style="dim") + table.add_column(f"{self.field} (preserved)", style="green") + table.add_column("Lossless file", style="dim", overflow="fold") + for i, (lossless, e) in enumerate(sorted(entries.items()), 1): + table.add_row(str(i), _fmt(e["value"]), lossless) + console.print(table) + + # --- apply ------------------------------------------------------------- + + def apply(self) -> None: + store = snapshot_store.load(self.snapshot_file) + if not store: + logger.info( + f"[yellow]No snapshots in {self.snapshot_file} — run " + f"`rb-dates snapshot` (or lossless-tags --snapshot-dates) first." + ) + return + + # Resolve each lossless path to its (re-added) Rekordbox row. + planned: List[Tuple[str, SnapshotEntry, int, Optional[str]]] = [] # +rb_id,cur + pending: List[Tuple[str, str]] = [] # not re-added yet (keep in store) + for lossless, entry in sorted(store.items()): + rb_id = resolve_rb_id_by_host_path(lossless, self.path_map) + if rb_id is None: + pending.append((lossless, "not in Rekordbox yet — re-add it first")) + continue + current = read_rb_added_at_raw(rb_id, entry.get("rb_field", self.field)) + planned.append((lossless, entry, rb_id, current)) + + console.print( + f"[bold cyan]{'Dry run — ' if not should_write() else ''}Apply[/bold cyan] " + f"from {self.snapshot_file} (field={self.field})\n" + ) + self._print_apply_table(planned) + self._print_skipped(pending, noun="lossless file") + + actionable = [p for p in planned if p[3] != p[1]["value"]] + if not should_write(): + already = len(planned) - len(actionable) + console.print( + f"\n[bold]Would restore:[/bold] {len(actionable)} " + f"[bold]already correct:[/bold] {already} " + f"[bold]awaiting re-add:[/bold] {len(pending)}" + ) + if actionable: + console.print( + "\n[dim]Dry run — no DB changes. Re-run with --write to apply.[/dim]" + ) + return + + if not actionable: + logger.info("[green]Nothing to restore — every re-added row is correct.") + self._maybe_prune(store, [p[0] for p in planned]) + return + + self._write(store, actionable) + + def _print_apply_table( + self, planned: List[Tuple[str, SnapshotEntry, int, Optional[str]]] + ) -> None: + if not planned: + return + table = Table(title="Date restore plan (Rekordbox)") + table.add_column("#", justify="right", style="dim") + table.add_column("rb ID", justify="right", style="dim") + table.add_column("Current (now)", style="red") + table.add_column(f"Restore {self.field}", style="green") + table.add_column("Lossless file", style="dim", overflow="fold") + for i, (lossless, entry, rb_id, current) in enumerate(planned, 1): + same = current == entry["value"] + table.add_row( + str(i), + str(rb_id), + "[dim]= target[/dim]" if same else _fmt(current), + _fmt(entry["value"]), + lossless, + ) + console.print(table) + + # --- the guarded write ------------------------------------------------- + + def _write( + self, + store: SnapshotStore, + actionable: List[Tuple[str, SnapshotEntry, int, Optional[str]]], + ) -> None: + db_path = get_db_path() + n = len(actionable) + console.print( + Panel( + "[bold]Before writing the Rekordbox database, FREE UP THE DB:[/bold]\n" + " • Close Rekordbox on [red]every[/red] workstation (a clean close " + "checkpoints the WAL).\n" + " • Pause Resilio Sync (or confirm it is idle) so it doesn't sync a " + "half-written file or make conflict copies.\n" + " • Make sure nothing else has master.db open.\n\n" + f"This will restore {self.field} on [red]{n}[/red] track(s) at " + f"{db_path}.", + title="⚠ Rekordbox write", + border_style="red", + ) + ) + if not confirm_destructive( + f"This writes {n} Date-Added value(s) into the Rekordbox database.", + "WRITE-RB-DATES", + title="Write Rekordbox Dates", + ): + logger.info("[yellow]Aborted — confirmation token did not match.") + return + + # Quiescence guard (skippable only via --allow-running for scratch copies). + try: + assert_db_quiescent( + db_path, + wait_seconds=get_stability_wait(), + ignore_wal=should_ignore_wal(), + allow_running=should_allow_running(), + ) + except DBNotQuiescent as e: + logger.error(f"[red]Refusing to write — {e}") + return + + backup_dir = get_rb_date_backup_dir() or str( + PROJECT_ROOT / "rekordbox-db-backups" + ) + backup = backup_rekordbox_db(db_path, backup_dir) + console.print(f"[dim]Backed up master.db → {backup}[/dim]") + + rows = [(rb_id, entry["value"]) for _path, entry, rb_id, _cur in actionable] + try: + with RekordboxDBWriter() as writer: + result = writer.apply_date_updates(rows, self.field) + except Exception as e: # noqa: BLE001 + logger.error( + f"[red]Write failed: {e}\n" + f"[red]The database is unchanged on error; a backup is at {backup}." + ) + return + + console.print( + f"[bold green]✔ Restored {result['applied']} date(s) " + f"({result['skipped']} already correct). " + f"New localUpdateCount: {result['final_usn']}.[/bold green]" + ) + self._maybe_prune(store, [p[0] for p in actionable]) + console.print( + "\n[bold yellow]Keep Rekordbox closed on every workstation until " + "Resilio has propagated master.db to all nodes[/bold yellow], then " + "verify the dates and resume Resilio." + ) + + def _maybe_prune(self, store: SnapshotStore, applied_keys: List[str]) -> None: + if should_keep_applied(): + return + removed = snapshot_store.prune(store, applied_keys) + if removed: + snapshot_store.save(self.snapshot_file, store) + console.print( + f"[dim]Pruned {removed} applied entry(ies) from " + f"{self.snapshot_file}.[/dim]" + ) + + # --- shared rendering -------------------------------------------------- + + def _print_skipped(self, skipped: List[Tuple[str, str]], noun: str) -> None: + if not skipped: + return + console.print(f"\n[yellow]Skipped {len(skipped)} {noun}(s):[/yellow]") + for path, reason in skipped[:20]: + console.print(f" [yellow]{reason}[/yellow] — {path}") + if len(skipped) > 20: + console.print(f" [dim]… +{len(skipped) - 20} more[/dim]") diff --git a/src/rekordbox2plex/config.py b/src/rekordbox2plex/config.py index f8c9de5..634aded 100644 --- a/src/rekordbox2plex/config.py +++ b/src/rekordbox2plex/config.py @@ -3,6 +3,7 @@ import argparse from typing import Optional, List, Set from .utils.helpers import get_boolenv +from .utils.paths import PROJECT_ROOT _args: argparse.Namespace | None = None @@ -220,6 +221,55 @@ def get_tag_copy_limit() -> Optional[int]: return getattr(get_args(), "limit", None) +def should_snapshot_dates() -> bool: + """`lossless-tags --snapshot-dates`: while copying, capture each lossy file's + Rekordbox Date Added into the rb-dates sidecar (read-only RB access), so it can + be restored onto the re-added lossless file later. See [[rb-dates]].""" + return getattr(get_args(), "snapshot_dates", False) + + +# --- rb-dates subcommand ------------------------------------------------------ + + +def get_rb_dates_mode() -> str: + """`rb-dates`: 'snapshot' (capture, read-only) or 'apply' (restore).""" + return getattr(get_args(), "rb_dates_mode", "snapshot") + + +def get_rb_date_snapshot_path() -> str: + """Sidecar JSON holding captured dates. CLI --snapshot-file wins, then + RB_DATE_SNAPSHOT_PATH, else ./rb-date-snapshots.json.""" + return ( + getattr(get_args(), "snapshot_file", None) + or os.getenv("RB_DATE_SNAPSHOT_PATH") + or str(PROJECT_ROOT / "rb-date-snapshots.json") + ) + + +def get_rb_date_backup_dir() -> Optional[str]: + """Where master.db is backed up before an `rb-dates apply --write`. CLI + --backup-dir wins, then RB_DATE_BACKUP_DIR; None → action default.""" + return getattr(get_args(), "backup_dir", None) or os.getenv("RB_DATE_BACKUP_DIR") + + +def get_stability_wait() -> float: + """`rb-dates apply`: seconds the quiescence probe waits between stat samples + (the master.db must be unchanged across the window).""" + return float(getattr(get_args(), "stability_wait", 5.0) or 5.0) + + +def should_ignore_wal() -> bool: + """`rb-dates apply`: proceed even if a non-empty master.db-wal exists + (advanced — normally a sign Rekordbox is still open).""" + return getattr(get_args(), "ignore_wal", False) + + +def should_keep_applied() -> bool: + """`rb-dates apply`: keep successfully-restored entries in the sidecar instead + of pruning them (default prunes so the backlog shrinks).""" + return getattr(get_args(), "keep_applied", False) + + # --- artist-images subcommand ------------------------------------------------- diff --git a/src/rekordbox2plex/rekordbox/RekordboxDBWriter.py b/src/rekordbox2plex/rekordbox/RekordboxDBWriter.py new file mode 100644 index 0000000..ce03e8b --- /dev/null +++ b/src/rekordbox2plex/rekordbox/RekordboxDBWriter.py @@ -0,0 +1,154 @@ +"""The ONLY write path to the Rekordbox ``master.db`` — restoring ``created_at`` +("Date Added") onto a re-added track for the ``rb-dates`` command. + +This is the sanctioned exception to "treat the Rekordbox DB as read-only": every +other access opens a temp copy ``mode=ro`` (``RekordboxDB.py``). Here we open the +**real** file read-write, after the caller has guarded it (Rekordbox closed, +Resilio idle, backup taken — see ``utils/rb_db_safety.py``). + +Each row update is USN-correct, mirroring what Rekordbox itself does for a local +edit: bump the global counter ``agentRegistry.localUpdateCount.int_1`` and stamp +the row's ``rb_local_usn`` with the new value, plus ``updated_at`` = now. After +committing we ``wal_checkpoint(TRUNCATE)`` so the change lands in ``master.db`` +itself (empty ``-wal``) — essential so Resilio propagates one consistent file. +""" + +from datetime import datetime, timezone +from typing import List, Optional, Sequence, Tuple + +from pysqlcipher3 import dbapi2 as sqlite + +from ..config import get_db_pass, get_db_path, get_rb_added_at_field + +# Only these djmdContent columns may be targeted — column names can't be bound as +# SQL parameters, so this allowlist prevents any injection via the configured field. +WRITABLE_DATE_FIELDS = ("created_at", "StockDate", "DateCreated") + +# (rb_id, raw_timestamp_string) +DateRow = Tuple[int, str] + + +def rb_now() -> str: + """Current UTC time in Rekordbox's stored format: ``YYYY-MM-DD HH:MM:SS.mmm + +00:00`` (millisecond precision, space before the offset).""" + dt = datetime.now(timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M:%S.") + f"{dt.microsecond // 1000:03d} +00:00" + + +def build_date_updates( + rows: Sequence[DateRow], + start_usn: int, + now_str: str, + field: str = "created_at", +) -> Tuple[List[Tuple[str, tuple]], int]: + """Build the (sql, params) statements for a USN-correct date restore. + + Each row consumes the next USN (``start_usn+1 … start_usn+n``) on its + ``rb_local_usn``; a final statement sets the global counter to ``start_usn+n``. + Returns (statements, final_usn). Pure — no DB access — so it's unit-testable.""" + if field not in WRITABLE_DATE_FIELDS: + raise ValueError(f"Refusing to write unknown date field {field!r}") + stmts: List[Tuple[str, tuple]] = [] + usn = start_usn + for rb_id, value in rows: + usn += 1 + stmts.append( + ( + f"UPDATE djmdContent SET {field} = ?, updated_at = ?, " + "rb_local_usn = ? WHERE ID = ?", + (value, now_str, usn, rb_id), + ) + ) + if rows: + stmts.append( + ( + "UPDATE agentRegistry SET int_1 = ?, updated_at = ? " + "WHERE registry_id = 'localUpdateCount'", + (usn, now_str), + ) + ) + return stmts, usn + + +class RekordboxDBWriter: + """Read-write SQLCipher connection to the real ``master.db``. Use as a context + manager so the connection is always closed.""" + + def __init__( + self, db_path: Optional[str] = None, password: Optional[str] = None + ) -> None: + self._db_path = db_path or get_db_path() + self._password = password or get_db_pass() + self._conn: Optional[sqlite.Connection] = None + + def __enter__(self) -> "RekordboxDBWriter": + uri = f"file:{self._db_path}?mode=rw" + self._conn = sqlite.connect(uri, uri=True) + cur = self._conn.cursor() + cur.execute(f"PRAGMA key='{self._password}';") + cur.execute("PRAGMA busy_timeout = 5000") + return self + + def __exit__(self, *exc) -> None: + if self._conn is not None: + self._conn.close() + self._conn = None + + @property + def _cursor(self): + assert self._conn is not None, "writer used outside its context manager" + return self._conn.cursor() + + def read_local_update_count(self) -> int: + cur = self._cursor + cur.execute( + "SELECT int_1 FROM agentRegistry WHERE registry_id = 'localUpdateCount'" + ) + row = cur.fetchone() + if row is None or row[0] is None: + raise RuntimeError("agentRegistry.localUpdateCount missing — aborting") + return int(row[0]) + + def read_current_value( + self, rb_id: int, field: str = "created_at" + ) -> Optional[str]: + if field not in WRITABLE_DATE_FIELDS: + raise ValueError(f"Unknown date field {field!r}") + cur = self._cursor + cur.execute(f"SELECT {field} FROM djmdContent WHERE ID = ?", (rb_id,)) + row = cur.fetchone() + return None if row is None else (None if row[0] is None else str(row[0])) + + def apply_date_updates( + self, rows: Sequence[DateRow], field: Optional[str] = None + ) -> dict: + """Restore the dates for ``rows`` (skipping any already equal), bumping the + USN, committing, and truncating the WAL. Returns counts + the final USN.""" + field = field or get_rb_added_at_field() + assert self._conn is not None + # Idempotent skip: drop rows already at the target value. + to_write: List[DateRow] = [] + skipped = 0 + for rb_id, value in rows: + if self.read_current_value(rb_id, field) == value: + skipped += 1 + else: + to_write.append((rb_id, value)) + + if not to_write: + return {"applied": 0, "skipped": skipped, "final_usn": None} + + start_usn = self.read_local_update_count() + stmts, final_usn = build_date_updates(to_write, start_usn, rb_now(), field) + cur = self._conn.cursor() + try: + cur.execute("BEGIN") + for sql, params in stmts: + cur.execute(sql, params) + self._conn.commit() + except BaseException: + self._conn.rollback() + raise + # Fold the WAL into master.db so Resilio carries a single consistent file. + cur.execute("PRAGMA wal_checkpoint(TRUNCATE)") + return {"applied": len(to_write), "skipped": skipped, "final_usn": final_usn} diff --git a/src/rekordbox2plex/rekordbox/date_snapshot.py b/src/rekordbox2plex/rekordbox/date_snapshot.py new file mode 100644 index 0000000..105970f --- /dev/null +++ b/src/rekordbox2plex/rekordbox/date_snapshot.py @@ -0,0 +1,49 @@ +"""Capture each lossy file's Rekordbox "Date Added" before a lossy→lossless swap. + +Shared by the ``rb-dates snapshot`` command and the ``lossless-tags +--snapshot-dates`` hook. For each (lossy, lossless) pair we resolve the **lossy** +file to its Rekordbox row (read-only) and read the raw added-at string, keyed by +the **lossless** path so ``rb-dates apply`` can restore it onto the re-added row. +""" + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Sequence, Tuple + +from ..config import get_rb_added_at_field +from ..utils.media_paths import PathMap +from ..utils.snapshot_store import SnapshotEntry +from .resolvers.added_at import read_rb_added_at_raw +from .resolvers.track import resolve_rb_id_by_host_path + +# (lossy_path, lossless_path) +Pair = Tuple[str, str] + + +def capture_dates( + pairs: Sequence[Pair], + media_map: PathMap, + field: Optional[str] = None, +) -> Tuple[Dict[str, SnapshotEntry], List[Tuple[str, str]]]: + """Return ({lossless_path: entry}, skipped). ``skipped`` is (lossy_path, reason) + for pairs whose lossy file isn't in Rekordbox or has no added-at value.""" + field = field or get_rb_added_at_field() + captured_at = datetime.now(timezone.utc).isoformat() + entries: Dict[str, SnapshotEntry] = {} + skipped: List[Tuple[str, str]] = [] + for lossy, lossless in pairs: + rb_id = resolve_rb_id_by_host_path(lossy, media_map) + if rb_id is None: + skipped.append((lossy, "lossy file not found in Rekordbox")) + continue + raw = read_rb_added_at_raw(rb_id, field) + if raw is None: + skipped.append((lossy, f"no {field} on Rekordbox row {rb_id}")) + continue + entries[lossless] = SnapshotEntry( + lossy_path=lossy, + rb_field=field, + value=raw, + source_rb_id=rb_id, + captured_at=captured_at, + ) + return entries, skipped diff --git a/src/rekordbox2plex/rekordbox/resolvers/added_at.py b/src/rekordbox2plex/rekordbox/resolvers/added_at.py index 21b510c..65e67ad 100644 --- a/src/rekordbox2plex/rekordbox/resolvers/added_at.py +++ b/src/rekordbox2plex/rekordbox/resolvers/added_at.py @@ -78,3 +78,15 @@ def resolve_rb_added_at( tz_name = tz_name if tz_name is not None else get_rekordbox_tz() raw = get_rb_timestamps(rb_id).get(field) return to_epoch(parse_rb_timestamp(raw, tz_name)) + + +def read_rb_added_at_raw(rb_id: int, field: Optional[str] = None) -> Optional[str]: + """Return the **raw, unparsed** added-at string (the configured field, default + ``created_at``) for a Rekordbox track ID, e.g. ``2020-09-17 22:16:29.060 + +00:00`` — or None if absent. Used by the ``rb-dates`` snapshot so the value + can be restored byte-for-byte later.""" + field = field or get_rb_added_at_field() + raw = get_rb_timestamps(rb_id).get(field) + if raw is None or not str(raw).strip(): + return None + return str(raw) diff --git a/src/rekordbox2plex/rekordbox/resolvers/track.py b/src/rekordbox2plex/rekordbox/resolvers/track.py index 3600023..2307364 100755 --- a/src/rekordbox2plex/rekordbox/resolvers/track.py +++ b/src/rekordbox2plex/rekordbox/resolvers/track.py @@ -2,6 +2,7 @@ from ...utils.progress_bar import Progress, TaskID, NullProgress from ...utils.logger import logger from ...utils.folder_mappings import get_folder_mappings +from ...utils.media_paths import PathMap, resolve_container_path from ...plex.data_types import PlexTrackWrapper @@ -74,6 +75,25 @@ def resolve_track_id_by_plex_path(plex_file_path: str) -> int | None: return resolve_track_id_by_rb_path(convert_path_to_rekordbox(plex_file_path)) +def resolve_rb_id_by_host_path( + host_path: str, media_map: PathMap | None = None +) -> int | None: + """Resolve a Rekordbox track ID from a HOST filesystem path (as walked by the + ``lossless-tags``/``rb-dates`` filesystem pass). + + Bridges the path representations: host path → Plex *container* path (reverse + ``PLEX_MEDIA_PATH_MAP``) → Rekordbox ``FolderPath`` (folderMappings.json) → + ``djmdContent.ID``. If no media map applies (e.g. run on the Mac where the host + path already matches what Rekordbox stored), the path passes straight through + ``convert_path_to_rekordbox``.""" + plex_path = host_path + if media_map: + container = resolve_container_path(host_path, media_map) + if container is not None: + plex_path = container + return resolve_track_id_by_plex_path(plex_path) + + def resolve_track_id( plex_track: PlexTrackWrapper, progress: Progress | NullProgress | None = None, diff --git a/src/rekordbox2plex/utils/helpers.py b/src/rekordbox2plex/utils/helpers.py index bcf02b2..f139ab6 100644 --- a/src/rekordbox2plex/utils/helpers.py +++ b/src/rekordbox2plex/utils/helpers.py @@ -420,6 +420,14 @@ def parse_script_arguments() -> argparse.Namespace: dest="ignore_case", help="Match basenames case-insensitively (default: exact match).", ) + lossless_tags.add_argument( + "--snapshot-dates", + action="store_true", + dest="snapshot_dates", + help="While copying, capture each lossy file's Rekordbox 'Date Added' into " + "the rb-dates sidecar (read-only) so it can be restored after you re-add " + "the lossless file in Rekordbox.", + ) lossless_tags.add_argument( "--limit", default=None, @@ -434,4 +442,117 @@ def parse_script_arguments() -> argparse.Namespace: help="Directory to back up lossless originals before the ID3 overwrite " "(default: ./lossless-tag-backups).", ) + + # rekordbox2plex rb-dates {snapshot,apply} ... + rb_dates = subparsers.add_parser( + "rb-dates", + parents=[common], + help="Preserve Rekordbox 'Date Added' across a lossy→lossless swap: " + "snapshot the old date, then restore it onto the re-added file.", + ) + rb_modes = rb_dates.add_subparsers(dest="rb_dates_mode", required=True) + + rb_snapshot = rb_modes.add_parser( + "snapshot", + parents=[common], + help="Capture each lossy file's Rekordbox Date Added into the sidecar " + "(read-only).", + ) + rb_snapshot.add_argument( + "--root", + default=None, + metavar="DIR", + help="Music root to walk for lossy/lossless pairs (env MUSIC_ROOT).", + ) + rb_snapshot.add_argument( + "--snapshot-file", + dest="snapshot_file", + default=None, + help="Sidecar JSON path (env RB_DATE_SNAPSHOT_PATH; " + "default ./rb-date-snapshots.json).", + ) + rb_snapshot.add_argument( + "--dry-run", + action="store_true", + help="Preview what would be captured without writing the sidecar.", + ) + rb_snapshot.add_argument( + "--lossy-exts", + dest="lossy_exts", + default=None, + metavar="EXTS", + help="Comma-separated lossy source extensions (default '.mp3').", + ) + rb_snapshot.add_argument( + "--lossless-exts", + dest="lossless_exts", + default=None, + metavar="EXTS", + help="Comma-separated lossless target extensions (default '.aiff,.aif').", + ) + rb_snapshot.add_argument( + "--ignore-case", + dest="ignore_case", + action="store_true", + help="Match basenames case-insensitively (default: exact match).", + ) + + rb_apply = rb_modes.add_parser( + "apply", + parents=[common], + help="Restore captured dates onto the re-added Rekordbox rows " + "(read-only unless --write; Rekordbox must be closed everywhere).", + ) + rb_apply.add_argument( + "--snapshot-file", + dest="snapshot_file", + default=None, + help="Sidecar JSON path (env RB_DATE_SNAPSHOT_PATH; " + "default ./rb-date-snapshots.json).", + ) + rb_apply.add_argument( + "--dry-run", + action="store_true", + help="Preview the old→restored date table without writing (also the " + "default; overrides --write).", + ) + rb_apply.add_argument( + "--write", + action="store_true", + help="Write the dates into the Rekordbox DB (requires the WRITE-RB-DATES " + "token; Rekordbox closed + Resilio idle).", + ) + rb_apply.add_argument( + "--backup-dir", + dest="backup_dir", + default=None, + help="Where to back up master.db before writing " + "(env RB_DATE_BACKUP_DIR; default ./rekordbox-db-backups).", + ) + rb_apply.add_argument( + "--ignore-wal", + dest="ignore_wal", + action="store_true", + help="Proceed even if a non-empty master.db-wal exists (advanced — " + "normally means Rekordbox is still open).", + ) + rb_apply.add_argument( + "--stability-wait", + dest="stability_wait", + type=float, + default=5.0, + metavar="SECONDS", + help="Seconds the quiescence probe waits between stat samples (default 5).", + ) + rb_apply.add_argument( + "--keep-applied", + dest="keep_applied", + action="store_true", + help="Keep restored entries in the sidecar instead of pruning them.", + ) + rb_apply.add_argument( + "--allow-running", + action="store_true", + help="Bypass the open/stability guards (only for scratch-copy testing).", + ) return parser.parse_args() diff --git a/src/rekordbox2plex/utils/pairing.py b/src/rekordbox2plex/utils/pairing.py new file mode 100644 index 0000000..a84142a --- /dev/null +++ b/src/rekordbox2plex/utils/pairing.py @@ -0,0 +1,67 @@ +"""Pair lossy files with their lossless replacements by basename within a folder. + +Shared by ``lossless-tags`` (copy the tags across) and ``rb-dates`` (carry the +Rekordbox "Date Added" across). A pair is a lossy file and a lossless file in the +**same directory** whose names match once the extension is stripped. +""" + +import os +from typing import Dict, List, Optional, Tuple + +# (lossy_path, lossless_path) +Pair = Tuple[str, str] + + +def classify_ext( + ext: str, lossy_exts: Tuple[str, ...], lossless_exts: Tuple[str, ...] +) -> Optional[str]: + ext = ext.lower() + if ext in lossy_exts: + return "lossy" + if ext in lossless_exts: + return "lossless" + return None + + +def walk_pairs( + root: str, + lossy_exts: Tuple[str, ...], + lossless_exts: Tuple[str, ...], + ignore_case: bool = False, +) -> Tuple[List[Pair], List[str], List[Tuple[str, str]]]: + """Walk ``root`` and return (pairs, unmatched, ambiguous). + + pairs: (lossy, lossless) where exactly one lossless sibling matched. + unmatched: lossy files with no lossless sibling (the upgrade backlog). + ambiguous: (lossy, reason) where more than one lossless sibling matched — + skipped rather than guessed.""" + pairs: List[Pair] = [] + unmatched: List[str] = [] + ambiguous: List[Tuple[str, str]] = [] + + for dirpath, _dirnames, filenames in os.walk(root): + groups: Dict[str, Dict[str, List[str]]] = {} + for fn in filenames: + stem, ext = os.path.splitext(fn) + kind = classify_ext(ext, lossy_exts, lossless_exts) + if kind is None: + continue + key = stem.lower() if ignore_case else stem + groups.setdefault(key, {"lossy": [], "lossless": []})[kind].append( + os.path.join(dirpath, fn) + ) + for grp in groups.values(): + for lossy in grp["lossy"]: + losslesses = grp["lossless"] + if not losslesses: + unmatched.append(lossy) + elif len(losslesses) > 1: + names = ", ".join(os.path.basename(p) for p in losslesses) + ambiguous.append((lossy, f">1 lossless sibling ({names})")) + else: + pairs.append((lossy, losslesses[0])) + + pairs.sort(key=lambda t: t[1].lower()) + unmatched.sort(key=str.lower) + ambiguous.sort(key=lambda t: t[0].lower()) + return pairs, unmatched, ambiguous diff --git a/src/rekordbox2plex/utils/rb_db_safety.py b/src/rekordbox2plex/utils/rb_db_safety.py new file mode 100644 index 0000000..3351546 --- /dev/null +++ b/src/rekordbox2plex/utils/rb_db_safety.py @@ -0,0 +1,137 @@ +"""Preconditions that make a write to the Resilio-synced Rekordbox ``master.db`` +safe — the "free up the DB before we touch it" guard. + +The DB is a single file synced across workstations by Resilio, and Rekordbox may +be open on any of them. We can't see a remote Rekordbox from here, so the real +protection is: refuse to write unless the file looks quiescent (no open handle, no +in-flight WAL, not being modified), and always back it up first. These checks are +heuristics layered behind an explicit human checklist — defence in depth, not a +substitute for closing Rekordbox everywhere. +""" + +import os +import shutil +import subprocess +import time +from datetime import datetime +from typing import Callable, List, Optional, Tuple + + +class DBNotQuiescent(Exception): + """Raised when ``master.db`` looks in-use/in-flight and a write must abort.""" + + +def sidecar_paths(db_path: str) -> List[str]: + """The SQLite sidecars Resilio must carry alongside the main DB.""" + return [db_path + suffix for suffix in ("-wal", "-shm")] + + +def wal_state(db_path: str) -> Tuple[bool, int]: + """Return (dirty, wal_size_bytes). ``dirty`` is True if a non-empty ``-wal`` + or any ``-shm`` exists — a sign of an open or uncleanly-closed connection.""" + wal = db_path + "-wal" + shm = db_path + "-shm" + wal_size = os.path.getsize(wal) if os.path.isfile(wal) else 0 + shm_present = os.path.isfile(shm) + return (wal_size > 0 or shm_present, wal_size) + + +def _stat_signature(db_path: str) -> tuple: + """A (path, size, mtime_ns) tuple for the DB and its ``-wal`` — changes if + anything writes to either between samples.""" + sig: List[tuple] = [] + for p in (db_path, db_path + "-wal"): + try: + st = os.stat(p) + sig.append((p, st.st_size, st.st_mtime_ns)) + except FileNotFoundError: + sig.append((p, None, None)) + return tuple(sig) + + +def probe_stable( + db_path: str, + wait_seconds: float, + samples: int = 2, + sleep_func: Callable[[float], None] = time.sleep, +) -> bool: + """Require ``samples`` consecutive identical stat signatures, each separated by + ``wait_seconds``. Any change ⇒ something (Resilio mid-sync, Rekordbox) is + writing ⇒ not stable. ``sleep_func`` is injectable for tests.""" + prev = _stat_signature(db_path) + for _ in range(max(1, samples)): + sleep_func(wait_seconds) + cur = _stat_signature(db_path) + if cur != prev: + return False + prev = cur + return True + + +def local_open_handle(db_path: str) -> Optional[str]: + """Best-effort: if a process *on this host* holds ``master.db`` open, return a + one-line description, else None. Can't see a remote Rekordbox — that's what the + human checklist is for. Silently returns None if ``lsof`` is unavailable.""" + try: + out = subprocess.run( + ["lsof", "--", db_path], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return None + lines = [ln for ln in out.stdout.splitlines()[1:] if ln.strip()] + return lines[0] if lines else None + + +def backup_rekordbox_db(db_path: str, backup_dir: str) -> str: + """Copy ``master.db`` (+ any ``-wal``/``-shm``) into a timestamped subdir of + ``backup_dir``. Returns the backup directory path (the recovery point).""" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = os.path.join(backup_dir, f"master.db-{stamp}") + os.makedirs(dest, exist_ok=True) + shutil.copy2(db_path, os.path.join(dest, os.path.basename(db_path))) + for side in sidecar_paths(db_path): + if os.path.isfile(side): + shutil.copy2(side, os.path.join(dest, os.path.basename(side))) + return dest + + +def assert_db_quiescent( + db_path: str, + *, + wait_seconds: float, + samples: int = 2, + ignore_wal: bool = False, + allow_running: bool = False, +) -> None: + """Raise ``DBNotQuiescent`` (with an actionable message) unless the DB looks + free to write. ``allow_running`` bypasses every check (scratch-copy testing).""" + if allow_running: + return + if not os.path.isfile(db_path): + raise DBNotQuiescent(f"Rekordbox DB not found at {db_path}") + + dirty, wal_size = wal_state(db_path) + if dirty and not ignore_wal: + raise DBNotQuiescent( + f"{db_path}-wal is non-empty ({wal_size} bytes) or a -shm exists — " + "Rekordbox is likely open or was not closed cleanly. Close it on every " + "workstation (a clean close checkpoints the WAL), then retry " + "(or pass --ignore-wal if you are certain)." + ) + + handle = local_open_handle(db_path) + if handle is not None: + raise DBNotQuiescent( + f"A process on this host has {db_path} open:\n {handle}\n" + "Close it before writing." + ) + + if not probe_stable(db_path, wait_seconds, samples): + raise DBNotQuiescent( + f"{db_path} changed during the {wait_seconds:g}s stability window — " + "something is still writing it (Resilio mid-sync, or Rekordbox open). " + "Pause Resilio and close Rekordbox everywhere, then retry." + ) diff --git a/src/rekordbox2plex/utils/snapshot_store.py b/src/rekordbox2plex/utils/snapshot_store.py new file mode 100644 index 0000000..e420a6c --- /dev/null +++ b/src/rekordbox2plex/utils/snapshot_store.py @@ -0,0 +1,79 @@ +"""The sidecar store for the ``rb-dates`` snapshot→apply flow. + +When a lossy file is about to be swapped for a lossless one, we capture the +lossy file's Rekordbox "Date Added" (raw ``created_at`` string) *before* the +swap and stash it here, keyed by the **lossless** file's path. After the user +re-adds the lossless file in Rekordbox (which resets its date to "now"), the +``apply`` phase reads this back and restores the original date onto the new row. + +The store is a plain JSON object: ``{ lossless_path: entry }``. Snapshots merge +into it across runs; ``apply`` prunes entries it has successfully restored so the +backlog shrinks and re-runs stay idempotent. Storing the *raw* timestamp string +means we restore it byte-for-byte — no timezone reconstruction. +""" + +import json +import os +import tempfile +from typing import Dict, Iterable, Optional, TypedDict + + +class SnapshotEntry(TypedDict): + lossy_path: str + rb_field: str + value: str # the raw Rekordbox timestamp, e.g. "2020-09-17 22:16:29.060 +00:00" + source_rb_id: int + captured_at: str # ISO-8601 of when we captured it (provenance only) + + +SnapshotStore = Dict[str, SnapshotEntry] + + +def load(path: str) -> SnapshotStore: + """Load the sidecar, or return an empty store if it's missing/unreadable.""" + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(data, dict): + return {} + return data + + +def save(path: str, store: SnapshotStore) -> None: + """Atomically write the sidecar (temp file + os.replace) as pretty JSON.""" + parent = os.path.dirname(os.path.abspath(path)) + os.makedirs(parent, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(store, f, indent=2, sort_keys=True, ensure_ascii=False) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.remove(tmp) + raise + + +def merge_entry(store: SnapshotStore, lossless_path: str, entry: SnapshotEntry) -> bool: + """Insert/replace one entry keyed by the lossless path. Returns True when the + stored content changed (new key, or a different captured value).""" + prev: Optional[SnapshotEntry] = store.get(lossless_path) + store[lossless_path] = entry + return prev is None or prev.get("value") != entry.get("value") + + +def prune(store: SnapshotStore, keys: Iterable[str]) -> int: + """Remove the given lossless-path keys (e.g. successfully-applied ones). + Returns how many were removed.""" + removed = 0 + for k in keys: + if store.pop(k, None) is not None: + removed += 1 + return removed diff --git a/tests/pairing_test.py b/tests/pairing_test.py new file mode 100644 index 0000000..7b338be --- /dev/null +++ b/tests/pairing_test.py @@ -0,0 +1,45 @@ +from rekordbox2plex.utils.pairing import walk_pairs + +LOSSY = (".mp3",) +LOSSLESS = (".aiff", ".aif") + + +def _touch(p): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"x") + + +def test_pairs_unmatched_and_ambiguous(tmp_path): + _touch(tmp_path / "A/song.mp3") + _touch(tmp_path / "A/song.aiff") # clean pair + _touch(tmp_path / "A/orphan.mp3") # no lossless sibling + _touch(tmp_path / "B/dup.mp3") + _touch(tmp_path / "B/dup.aiff") + _touch(tmp_path / "B/dup.aif") # ambiguous: two lossless siblings + + pairs, unmatched, ambiguous = walk_pairs(str(tmp_path), LOSSY, LOSSLESS) + + assert pairs == [(str(tmp_path / "A/song.mp3"), str(tmp_path / "A/song.aiff"))] + assert unmatched == [str(tmp_path / "A/orphan.mp3")] + assert len(ambiguous) == 1 + assert ">1 lossless sibling" in ambiguous[0][1] + + +def test_case_sensitivity(tmp_path): + _touch(tmp_path / "Song.mp3") + _touch(tmp_path / "song.aiff") + + pairs, unmatched, _ = walk_pairs(str(tmp_path), LOSSY, LOSSLESS, ignore_case=False) + assert pairs == [] and len(unmatched) == 1 + + pairs, unmatched, _ = walk_pairs(str(tmp_path), LOSSY, LOSSLESS, ignore_case=True) + assert len(pairs) == 1 and unmatched == [] + + +def test_non_configured_extensions_are_ignored(tmp_path): + _touch(tmp_path / "t.mp3") + _touch(tmp_path / "t.flac") # not in LOSSLESS → not a sibling + + pairs, unmatched, ambiguous = walk_pairs(str(tmp_path), LOSSY, LOSSLESS) + assert pairs == [] and not ambiguous + assert unmatched == [str(tmp_path / "t.mp3")] diff --git a/tests/rb_date_updates_test.py b/tests/rb_date_updates_test.py new file mode 100644 index 0000000..720e7e8 --- /dev/null +++ b/tests/rb_date_updates_test.py @@ -0,0 +1,46 @@ +import re + +import pytest + +from rekordbox2plex.rekordbox.RekordboxDBWriter import build_date_updates, rb_now + + +def test_usn_sequence_and_global_counter(): + rows = [(10, "D1"), (20, "D2"), (30, "D3")] + stmts, final_usn = build_date_updates(rows, start_usn=100, now_str="NOW") + + # One UPDATE per row (rb_local_usn = 101, 102, 103) + the global counter row. + assert len(stmts) == 4 + assert final_usn == 103 + for i, (rb_id, _val) in enumerate(rows): + sql, params = stmts[i] + assert "UPDATE djmdContent SET created_at = ?" in sql + assert "rb_local_usn = ?" in sql + # params: (value, now, usn, rb_id) + assert params == (rows[i][1], "NOW", 101 + i, rb_id) + # Final statement bumps agentRegistry.localUpdateCount to the last USN. + last_sql, last_params = stmts[-1] + assert "UPDATE agentRegistry SET int_1 = ?" in last_sql + assert "localUpdateCount" in last_sql + assert last_params == (103, "NOW") + + +def test_empty_rows_produce_no_statements(): + stmts, final_usn = build_date_updates([], start_usn=500, now_str="NOW") + assert stmts == [] + assert final_usn == 500 + + +def test_field_is_allowlisted(): + with pytest.raises(ValueError): + build_date_updates([(1, "x")], 0, "NOW", field="created_at; DROP TABLE x") + + +def test_alternate_field_targets_that_column(): + stmts, _ = build_date_updates([(1, "x")], 0, "NOW", field="StockDate") + assert "UPDATE djmdContent SET StockDate = ?" in stmts[0][0] + + +def test_rb_now_matches_rekordbox_format(): + # e.g. "2026-06-11 10:37:39.680 +00:00" + assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \+00:00", rb_now()) diff --git a/tests/rb_db_safety_test.py b/tests/rb_db_safety_test.py new file mode 100644 index 0000000..b503715 --- /dev/null +++ b/tests/rb_db_safety_test.py @@ -0,0 +1,69 @@ +import os + +import pytest + +from rekordbox2plex.utils.rb_db_safety import ( + DBNotQuiescent, + assert_db_quiescent, + backup_rekordbox_db, + probe_stable, + wal_state, +) + + +def _make_db(tmp_path, wal=b"", shm=False): + db = tmp_path / "master.db" + db.write_bytes(b"SQLite format 3\x00") + if wal: + (tmp_path / "master.db-wal").write_bytes(wal) + if shm: + (tmp_path / "master.db-shm").write_bytes(b"\x00") + return str(db) + + +def test_wal_state_detects_dirty(tmp_path): + clean = _make_db(tmp_path) + assert wal_state(clean) == (False, 0) + + sub = tmp_path / "d2" + sub.mkdir() + dirty = _make_db(sub, wal=b"\x01\x02\x03") + is_dirty, size = wal_state(dirty) + assert is_dirty and size == 3 + + +def test_probe_stable_true_when_untouched(tmp_path): + db = _make_db(tmp_path) + assert probe_stable(db, 0.0, samples=2, sleep_func=lambda _s: None) is True + + +def test_probe_stable_false_when_file_moves(tmp_path): + db = _make_db(tmp_path) + + def mutate(_seconds): + with open(db, "ab") as f: + f.write(b"more") # size change between samples + + assert probe_stable(db, 0.0, samples=2, sleep_func=mutate) is False + + +def test_assert_quiescent_refuses_nonempty_wal(tmp_path): + db = _make_db(tmp_path, wal=b"\x01\x02") + with pytest.raises(DBNotQuiescent): + assert_db_quiescent(db, wait_seconds=0.0) + # --ignore-wal bypasses just the WAL check + assert_db_quiescent(db, wait_seconds=0.0, ignore_wal=True) + + +def test_assert_quiescent_allow_running_bypasses_everything(tmp_path): + db = _make_db(tmp_path, wal=b"\x01\x02", shm=True) + # would otherwise raise; allow_running short-circuits + assert_db_quiescent(db, wait_seconds=0.0, allow_running=True) + + +def test_backup_copies_db_and_sidecars(tmp_path): + db = _make_db(tmp_path, wal=b"\x09", shm=True) + dest = backup_rekordbox_db(db, str(tmp_path / "backups")) + assert os.path.isfile(os.path.join(dest, "master.db")) + assert os.path.isfile(os.path.join(dest, "master.db-wal")) + assert os.path.isfile(os.path.join(dest, "master.db-shm")) diff --git a/tests/snapshot_store_test.py b/tests/snapshot_store_test.py new file mode 100644 index 0000000..332d902 --- /dev/null +++ b/tests/snapshot_store_test.py @@ -0,0 +1,52 @@ +from rekordbox2plex.utils import snapshot_store +from rekordbox2plex.utils.snapshot_store import SnapshotEntry + + +def _entry(value="2020-01-01 00:00:00.000 +00:00", lossy="/m/a.mp3", rb_id=1): + return SnapshotEntry( + lossy_path=lossy, + rb_field="created_at", + value=value, + source_rb_id=rb_id, + captured_at="2026-06-11T00:00:00+00:00", + ) + + +def test_save_load_roundtrip(tmp_path): + path = str(tmp_path / "snap.json") + store = {"/m/a.aiff": _entry()} + snapshot_store.save(path, store) + assert snapshot_store.load(path) == store + + +def test_load_missing_or_garbage_returns_empty(tmp_path): + assert snapshot_store.load(str(tmp_path / "nope.json")) == {} + bad = tmp_path / "bad.json" + bad.write_text("{not json") + assert snapshot_store.load(str(bad)) == {} + + +def test_merge_entry_reports_changes(): + store: dict = {} + assert snapshot_store.merge_entry(store, "/m/a.aiff", _entry(value="V1")) is True + # same value → no change + assert snapshot_store.merge_entry(store, "/m/a.aiff", _entry(value="V1")) is False + # different value → change + assert snapshot_store.merge_entry(store, "/m/a.aiff", _entry(value="V2")) is True + assert store["/m/a.aiff"]["value"] == "V2" + + +def test_prune_removes_only_given_keys(): + store = {"/a.aiff": _entry(), "/b.aiff": _entry(), "/c.aiff": _entry()} + removed = snapshot_store.prune(store, ["/a.aiff", "/c.aiff", "/missing.aiff"]) + assert removed == 2 + assert set(store) == {"/b.aiff"} + + +def test_merge_then_save_is_idempotent(tmp_path): + path = str(tmp_path / "snap.json") + store = snapshot_store.load(path) + snapshot_store.merge_entry(store, "/m/a.aiff", _entry()) + snapshot_store.save(path, store) + reloaded = snapshot_store.load(path) + assert reloaded == store