Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
8 changes: 5 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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).

Expand Down Expand Up @@ -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 <dir>` (or `MUSIC_ROOT`), `--dry-run`, `--snapshot-file <path>`, `--lossy-exts`/`--lossless-exts`, `--ignore-case`.
* **`apply`** — `--dry-run` (default) / `--write` (needs the `WRITE-RB-DATES` token), `--snapshot-file <path>`, `--backup-dir <dir>`, `--ignore-wal`, `--stability-wait <seconds>`, `--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).
Expand Down
6 changes: 6 additions & 0 deletions src/rekordbox2plex/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!")
Expand Down
133 changes: 67 additions & 66 deletions src/rekordbox2plex/actions/LosslessTagCopy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)."""
Expand All @@ -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]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading