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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ PLEX_PLAYLIST_FLATTENING_DELIMITER="/"
#PLEX_MEDIA_PATH_MAP="/data/music=/tank/music"
#AIFF_BACKUP_DIR="/path/to/aiff-title-backups" # default: ./aiff-title-backups

# Lossless tag copy (`lossless-tags` subcommand). Walks MUSIC_ROOT for a lossy file
# (MP3) sitting next to a same-named lossless replacement (AIFF) in the SAME folder,
# and copies the whole ID3 tag from the lossy file onto the lossless one so curated
# metadata survives the swap. Read-only by default. --refresh-plex additionally needs
# PLEX_MEDIA_PATH_MAP above (to map host dirs back to Plex paths for a partial scan).
#MUSIC_ROOT="/tank/music"
#TAG_BACKUP_DIR="/path/to/lossless-tag-backups" # default: ./lossless-tag-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
8 changes: 5 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,10 @@ cp .env.example .env
| `PLEX_SQLITE_BIN` | string | `/usr/lib/plexmediaserver/Plex SQLite` | (`dates` only) Path to that binary inside the image. |
| `REKORDBOX_ADDED_AT_FIELD` | string | `created_at` | (`dates` only) `djmdContent` column used as the source date. |
| `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` only) **Required.** 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. |
| `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`). |
| `TAG_BACKUP_DIR` | string | `./lossless-tag-backups` | (`lossless-tags` only) Where lossless originals are backed up before `--write` overwrites their ID3. |
| `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 @@ -162,7 +164,7 @@ Map each Plex path to the corresponding Rekordbox path:

## Usage

The tool has six subcommands — **`playlists`**, **`dates`**, **`parity`**, **`aiff-titles`**, **`artist-images`**, and **`clear-art`** — which you can run independently:
The tool has seven subcommands — **`playlists`**, **`dates`**, **`parity`**, **`aiff-titles`**, **`lossless-tags`**, **`artist-images`**, and **`clear-art`** — which you can run independently:

```bash
poetry run rekordbox2plex playlists # mirror Rekordbox playlists into Plex
Expand All @@ -171,6 +173,8 @@ poetry run rekordbox2plex dates --write # apply it (Plex must be stopped;
poetry run rekordbox2plex parity # audit metadata parity (read-only)
poetry run rekordbox2plex aiff-titles --dry-run # preview AIFF NAME-chunk title fixes (read-only)
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 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 @@ -181,6 +185,7 @@ poetry run rekordbox2plex clear-art --write # remove them (Plex must be st
- **`dates`** — full read-only-preview → write workflow in [Syncing "Date Added"](#syncing-date-added).
- **`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).
- **`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 @@ -308,6 +313,41 @@ After a write, Plex still shows the old title until it re-reads the file: either

---

## Porting tags to lossless files

If your collection is mostly lossless (AIFF) but still has some leftover **lossy** files (MP3) you're replacing over time, this command carries your curated metadata across the swap. The workflow: drop a fresh lossless file next to the old lossy one (**same basename, same folder**), then run `lossless-tags`. It finds each lossy file with a same-named lossless sibling and **copies the entire ID3 tag wholesale** — every frame, including embedded artwork — from the lossy file onto the lossless one. After that the lossless file carries the tags you'd carefully set, and you can delete the lossy original (or have the tool do it with `--delete-lossy`).

It walks `MUSIC_ROOT` (or `--root`) on disk — no Plex or Rekordbox lookup needed for the copy. **Read-only by default**: it prints the tags that would transfer per matched pair (a dry-run / parity check). `--write` overwrites the lossless file's ID3 behind a typed `WRITE-TAGS` confirmation, **backing up each lossless original first**. mutagen rewrites only the ID3 chunk, so the audio (`SSND`) is left byte-identical; the AIFF native `NAME` chunk is then aligned to the new title (so Plex shows it right), or stripped with `--remove-name`.

Only **AIFF/AIFF-C** lossless targets are supported (they carry ID3 like MP3 does); FLAC (Vorbis comments) and WAV are skipped. Ambiguous pairings (a lossy file with **more than one** lossless sibling), untagged lossy sources, and zero-byte/broken files are skipped and reported, never silently mangled — and the lossy file is **only** deleted once every step of its copy succeeded.

```bash
poetry run rekordbox2plex lossless-tags --root /tank/music --dry-run # preview tags that would transfer
poetry run rekordbox2plex lossless-tags --root /tank/music --show both # also list files not yet upgraded
poetry run rekordbox2plex lossless-tags --root /tank/music --write # copy tags (backs up lossless originals)
poetry run rekordbox2plex lossless-tags --root /tank/music --write --delete-lossy # …and remove the lossy source on success
poetry run rekordbox2plex lossless-tags --root /tank/music --write --refresh-plex # …and trigger a partial Plex scan
```

Use `--show unmatched` (or `both`) to list the lossy files that **don't** yet have a lossless replacement — i.e. your remaining upgrade backlog. `--refresh-plex` queues a partial Plex scan of the affected folders so Plex ingests the new file and drops the deleted one (it needs `PLEX_MEDIA_PATH_MAP` to map host directories back to Plex paths).

### `lossless-tags` arguments

* `--root <dir>` — music root to walk (or set `MUSIC_ROOT`).
* `--dry-run` — preview the per-pair tag diff without writing (also the default).
* `--write` — copy the tags onto the lossless files (requires the `WRITE-TAGS` token).
* `--show matched|unmatched|both` — which lossy files to list: matched (default), not-yet-upgraded, or both.
* `--lossy-exts <list>` / `--lossless-exts <list>` — override the source/target extensions (default `.mp3` / `.aiff,.aif`).
* `--remove-name` — strip the AIFF `NAME` chunk instead of setting it to the copied title.
* `--delete-lossy` — delete the lossy source after a fully successful copy.
* `--refresh-plex` — after writing, trigger a partial Plex scan of the affected folders (needs `PLEX_MEDIA_PATH_MAP`).
* `--mirror-version` — save ID3 as the source's major version instead of forcing v2.3.
* `--ignore-case` — match basenames case-insensitively (default: exact match).
* `--limit <N>` — process at most N matched pairs.
* `--backup-dir <dir>` — where to back up lossless originals (default `./lossless-tag-backups`, or `TAG_BACKUP_DIR`).

---

## 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 @@ -7,6 +7,7 @@
from .actions.DateAddedRestore import DateAddedRestore
from .actions.ParityCheck import ParityCheck
from .actions.AiffTitleFix import AiffTitleFix
from .actions.LosslessTagCopy import LosslessTagCopy
from .actions.ArtistImageSync import ArtistImageSync
from .actions.ArtworkClear import ArtworkClear
from .utils.confirm import confirm_destructive
Expand Down Expand Up @@ -40,6 +41,11 @@ def main():
logger.info("[bold green]✔ AIFF title fix finished!")
return

if config.get_command() == "lossless-tags":
LosslessTagCopy().run()
logger.info("[bold green]✔ Lossless tag copy finished!")
return

if config.get_command() == "artist-images":
ArtistImageSync().run()
logger.info("[bold green]✔ Artist image sync finished!")
Expand Down
Loading