diff --git a/.env.example b/.env.example index 3524b4a..94bd160 100644 --- a/.env.example +++ b/.env.example @@ -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), diff --git a/CLAUDE.md b/CLAUDE.md index e98bcc6..a3ce017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,14 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Scope -This tool does six things, exposed as subcommands: +This tool does seven 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`. 3. **`parity`** — a **strictly read-only** audit comparing Title/Artist/Album 1:1 between Rekordbox and Plex (AlbumArtist is **opt-in** via `--fields` — Rekordbox dedups `djmdAlbum` by name, so its album-artist is per-album not per-track and unreliable for same-named releases), plus orphan (one-system-only) coverage gaps. Reads Plex from the DB (`plex/PlexDBReader.py::read_tracks_metadata`) and Rekordbox via `rekordbox/resolvers/metadata.py`; matches by file path with the shared `resolve_track_id_by_plex_path`. Comparison is normalized (`utils/normalize.py` — trim/collapse-whitespace/casefold) but raw values are shown. **`--split-artists`** adds a fallback for multi-artist names: on a direct artist/albumartist miss it splits **both** sides into component artists via the shared `artwork/collab.py::split_collab` (separators reuse the `artist-images` collab config — primary always-on + opt-in ambiguous `&`/`+` via `--collab-extra-seps`/`ARTIST_COLLAB_EXTRA_SEPARATORS`) and compares the component **set** (order-independent unless `--split-artists-ordered`), so Plex `Fred V & Grafix` matches Rekordbox `Fred V, Grafix`. The fallback can only *remove* a mismatch and only when both sides decompose identically — it never relaxes `title`/`album`. **It must never write** to either DB: no `PlexDBWriter` import, no `--write`, `SELECT`-only. See `actions/ParityCheck.py`. 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. **`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`). +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`). ## Commands @@ -20,6 +21,7 @@ This tool does six things, exposed as subcommands: - Apply Date Added (destructive; Plex must be stopped): `poetry run rekordbox2plex dates --write` — see README "Syncing Date Added" for the manual backup + `docker compose down`/`up` runbook - 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. - 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` @@ -50,4 +52,4 @@ The flow is **Plex-driven**: walk every Plex track once to build an in-memory `P ### Configuration -Env vars are documented in `README.md` and `.env.example`. Most are required Plex/Rekordbox connection details; optional playlist knobs are `DELETE_ORPHANED_PLAYLISTS`, `REKORDBOX_PLAYLISTS_TO_IGNORE`, `PLEX_PLAYLIST_FLATTENING_DELIMITER`, and `FOLDER_MAPPINGS_PATH`. The `dates` subcommand adds `PLEX_DB_PATH` (host path to `com.plexapp.plugins.library.db`), `PLEX_CONTAINER_NAME` (default `plex`, checked stopped before a write), `PLEX_SQLITE_MECHANISM` (`docker`|`sqlite3`), `PLEX_DOCKER_IMAGE`, `PLEX_SQLITE_BIN`, `REKORDBOX_TZ` (only for naive timestamps), and `REKORDBOX_ADDED_AT_FIELD` (default `created_at`). The `parity` subcommand reuses `PLEX_DB_PATH` and adds `REKORDBOX_FOLDER_PATHS_TO_IGNORE` (comma-separated substrings; a track whose Rekordbox `FolderPath` contains any is excluded from the check on both sides — same substring convention as `REKORDBOX_PLAYLISTS_TO_IGNORE`). Its `--split-artists` fallback reuses the `artist-images` collab-separator env (`ARTIST_COLLAB_PRIMARY_SEPARATORS` + the opt-in `ARTIST_COLLAB_EXTRA_SEPARATORS`, also settable per-run via `--collab-extra-seps`). The `aiff-titles` subcommand reuses `PLEX_DB_PATH` and adds `PLEX_MEDIA_PATH_MAP` (**required** — comma-separated `container=host` path-prefix pairs, e.g. `/data/music=/tank/music`, so the tool can open the audio files since Plex stores container paths; longest prefix wins, parsed in `utils/media_paths.py`) and `AIFF_BACKUP_DIR` (optional; default `./aiff-title-backups`). The `artist-images` subcommand adds `PLEX_ARTIST_IMAGE_PROVIDERS` (default `fanarttv,theaudiodb,deezer,spotify,bandcamp,discogs`), provider credentials — `FANARTTV_API_KEY`, `DISCOGS_TOKEN` (or `DISCOGS_KEY`+`DISCOGS_SECRET`), `SPOTIFY_CLIENT_ID`+`SPOTIFY_CLIENT_SECRET`, `THEAUDIODB_API_KEY` (defaults to the public test key `2`) — and `MUSICBRAINZ_USER_AGENT`; providers with no/invalid credentials are skipped with a warning (Deezer/Bandcamp need none). Collab-split tuning (all separators configurable; word separators like `x`/`vs`/`feat` require spaces both sides, symbols like `,`/`&`/`+` don't): `ARTIST_COLLAB_PRIMARY_SEPARATORS` (always-on tier, default comma + feat/ft/featuring; whitespace-separated so `,` is a token), `ARTIST_COLLAB_EXTRA_SEPARATORS` (opt-in ambiguous tier, e.g. `& + x`; default off), `ARTIST_COLLAB_MIN_SCORE` (MB score for split pieces, default 95), `ARTIST_COLLAB_MIN_SEGMENT_LEN` (default 2). The `clear-art` subcommand reuses the `dates` write vars (`PLEX_DB_PATH`, `PLEX_CONTAINER_NAME`, `PLEX_SQLITE_MECHANISM`, `PLEX_DOCKER_IMAGE`, `PLEX_SQLITE_BIN`) and adds `PLEX_METADATA_PATH` (optional; the Plex `Metadata` dir for on-disk poster-file deletion, else derived from `PLEX_DB_PATH`). +Env vars are documented in `README.md` and `.env.example`. Most are required Plex/Rekordbox connection details; optional playlist knobs are `DELETE_ORPHANED_PLAYLISTS`, `REKORDBOX_PLAYLISTS_TO_IGNORE`, `PLEX_PLAYLIST_FLATTENING_DELIMITER`, and `FOLDER_MAPPINGS_PATH`. The `dates` subcommand adds `PLEX_DB_PATH` (host path to `com.plexapp.plugins.library.db`), `PLEX_CONTAINER_NAME` (default `plex`, checked stopped before a write), `PLEX_SQLITE_MECHANISM` (`docker`|`sqlite3`), `PLEX_DOCKER_IMAGE`, `PLEX_SQLITE_BIN`, `REKORDBOX_TZ` (only for naive timestamps), and `REKORDBOX_ADDED_AT_FIELD` (default `created_at`). The `parity` subcommand reuses `PLEX_DB_PATH` and adds `REKORDBOX_FOLDER_PATHS_TO_IGNORE` (comma-separated substrings; a track whose Rekordbox `FolderPath` contains any is excluded from the check on both sides — same substring convention as `REKORDBOX_PLAYLISTS_TO_IGNORE`). Its `--split-artists` fallback reuses the `artist-images` collab-separator env (`ARTIST_COLLAB_PRIMARY_SEPARATORS` + the opt-in `ARTIST_COLLAB_EXTRA_SEPARATORS`, also settable per-run via `--collab-extra-seps`). The `lossless-tags` subcommand is **filesystem-driven** (no Plex/Rekordbox DB for the core copy): it adds `MUSIC_ROOT` (the walk root, or `--root`) and `TAG_BACKUP_DIR` (default `./lossless-tag-backups`, where lossless originals are backed up before the ID3 overwrite). Its `--refresh-plex` flag additionally needs `PLEX_MEDIA_PATH_MAP` (host→container reverse-mapped via `utils/media_paths.py::resolve_container_path`) plus the Plex API connection (`PLEX_URL`/`PLEX_TOKEN`) to queue a partial scan. The `aiff-titles` subcommand reuses `PLEX_DB_PATH` and adds `PLEX_MEDIA_PATH_MAP` (**required** — comma-separated `container=host` path-prefix pairs, e.g. `/data/music=/tank/music`, so the tool can open the audio files since Plex stores container paths; longest prefix wins, parsed in `utils/media_paths.py`) and `AIFF_BACKUP_DIR` (optional; default `./aiff-title-backups`). The `artist-images` subcommand adds `PLEX_ARTIST_IMAGE_PROVIDERS` (default `fanarttv,theaudiodb,deezer,spotify,bandcamp,discogs`), provider credentials — `FANARTTV_API_KEY`, `DISCOGS_TOKEN` (or `DISCOGS_KEY`+`DISCOGS_SECRET`), `SPOTIFY_CLIENT_ID`+`SPOTIFY_CLIENT_SECRET`, `THEAUDIODB_API_KEY` (defaults to the public test key `2`) — and `MUSICBRAINZ_USER_AGENT`; providers with no/invalid credentials are skipped with a warning (Deezer/Bandcamp need none). Collab-split tuning (all separators configurable; word separators like `x`/`vs`/`feat` require spaces both sides, symbols like `,`/`&`/`+` don't): `ARTIST_COLLAB_PRIMARY_SEPARATORS` (always-on tier, default comma + feat/ft/featuring; whitespace-separated so `,` is a token), `ARTIST_COLLAB_EXTRA_SEPARATORS` (opt-in ambiguous tier, e.g. `& + x`; default off), `ARTIST_COLLAB_MIN_SCORE` (MB score for split pieces, default 95), `ARTIST_COLLAB_MIN_SEGMENT_LEN` (default 2). The `clear-art` subcommand reuses the `dates` write vars (`PLEX_DB_PATH`, `PLEX_CONTAINER_NAME`, `PLEX_SQLITE_MECHANISM`, `PLEX_DOCKER_IMAGE`, `PLEX_SQLITE_BIN`) and adds `PLEX_METADATA_PATH` (optional; the Plex `Metadata` dir for on-disk poster-file deletion, else derived from `PLEX_DB_PATH`). diff --git a/README.md b/README.md index 3b38281..69acde8 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 @@ -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) @@ -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). @@ -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 ` — 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 ` / `--lossless-exts ` — 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 ` — process at most N matched pairs. +* `--backup-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). diff --git a/src/rekordbox2plex/__main__.py b/src/rekordbox2plex/__main__.py index cb87895..f4d9ab7 100644 --- a/src/rekordbox2plex/__main__.py +++ b/src/rekordbox2plex/__main__.py @@ -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 @@ -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!") diff --git a/src/rekordbox2plex/actions/LosslessTagCopy.py b/src/rekordbox2plex/actions/LosslessTagCopy.py new file mode 100644 index 0000000..412e997 --- /dev/null +++ b/src/rekordbox2plex/actions/LosslessTagCopy.py @@ -0,0 +1,387 @@ +import os +import shutil +from typing import Dict, List, Optional, Set, Tuple + +from mutagen.id3 import ID3 +from rich.table import Table + +from ._ActionBase import ActionBase +from ..config import ( + get_lossless_exts, + get_lossy_exts, + get_music_root, + get_plex_media_path_map, + get_show_mode, + get_tag_backup_dir, + get_tag_copy_limit, + should_delete_lossy, + should_ignore_case, + should_mirror_id3_version, + should_refresh_plex, + should_remove_name, + should_write, +) +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.paths import PROJECT_ROOT +from ..utils.progress_bar import progress_instance + +# Only AIFF/AIFF-C carry ID3 in a way this wholesale copy + NAME-chunk fix +# supports. FLAC (Vorbis comments) and WAV are skipped even if configured. +AIFF_EXTS = (".aiff", ".aif") + +# (field key from read_id3_fields, column header) shown in the diff table. +_DIFF_COLS = [ + ("title", "Title"), + ("artist", "Artist"), + ("album", "Album"), + ("albumartist", "AlbumArtist"), + ("genre", "Genre"), +] + + +def _fmt(value: object) -> str: + if value is None or value == "": + return "[dim]—[/dim]" + return str(value) + + +class LosslessTagCopy(ActionBase): + """Copy ID3 tags wholesale from a leftover lossy file (MP3) onto a same-named + lossless replacement (AIFF) sitting in the same folder, so curated metadata + survives swapping the file. + + Read-only by default: walks the music root, pairs each lossy file with its + lossless sibling by basename, and prints the tags that would transfer. + ``--write`` overwrites the lossless file's ID3 (backing up each original), + aligns the AIFF ``NAME`` chunk so Plex shows the new title, and — with + ``--delete-lossy`` — removes the now-redundant lossy source.""" + + def __init__(self) -> None: + super().__init__("lossless tag copy") + self.root = get_music_root() + self.lossy_exts = get_lossy_exts() + self.lossless_exts = get_lossless_exts() + self.ignore_case = should_ignore_case() + self.remove_name = should_remove_name() + self.refresh = should_refresh_plex() + self.delete_lossy = should_delete_lossy() + self.mirror_version = should_mirror_id3_version() + self.show = get_show_mode() + self.limit = get_tag_copy_limit() + self.path_map = parse_media_path_map(get_plex_media_path_map()) + + def run(self) -> None: + if not self.root: + logger.error( + "[red]No music root set — pass --root or set MUSIC_ROOT." + ) + return + if not os.path.isdir(self.root): + logger.error(f"[red]Music root is not a directory: {self.root}") + return + if should_write(): + self.apply() + else: + self.preview() + + # --- 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).""" + real = os.path.realpath(path) + if not os.path.isfile(real): + return "broken symlink / not a regular file" + try: + if os.path.getsize(real) == 0: + return "zero-byte file" + except OSError as e: + return f"stat failed ({e})" + return None + + def compute( + self, + ) -> Tuple[List[Dict], List[str], List[Tuple[str, str]]]: + """Walk the root and return (pairs, unmatched, skipped). + + pair: {lossy, lossless, lossy_fields, lossless_fields, overwrite}. + 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() + 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) + skipped.sort(key=lambda t: t[0].lower()) + if self.limit is not None: + pairs = pairs[: self.limit] + return pairs, unmatched, skipped + + # --- rendering --------------------------------------------------------- + + def _print_pairs(self, pairs: List[Dict]) -> None: + title = "Tags to copy (lossy → lossless), incoming values shown" + table = Table(title=title) + table.add_column("#", justify="right", style="dim") + for _key, header in _DIFF_COLS: + table.add_column(header, style="green", overflow="fold") + table.add_column("Art", justify="center") + table.add_column("Overwrite?", justify="center") + table.add_column("Lossless file", style="dim", overflow="fold") + for i, p in enumerate(pairs, 1): + lf = p["lossy_fields"] + row = [str(i)] + row += [_fmt(lf.get(k)) for k, _ in _DIFF_COLS] + row.append("[green]✓[/green]" if lf.get("has_artwork") else "[dim]—[/dim]") + row.append("[yellow]yes[/yellow]" if p["overwrite"] else "[dim]no[/dim]") + row.append(p["lossless"]) + table.add_row(*row) + console.print(table) + + def _print_unmatched(self, unmatched: List[str]) -> None: + console.print( + f"\n[bold]Lossy files with no lossless replacement yet:[/bold] " + f"{len(unmatched)}" + ) + for p in unmatched: + console.print(f" [yellow]·[/yellow] {p}") + + def _print_skipped(self, skipped: List[Tuple[str, str]]) -> None: + if not skipped: + return + console.print(f"\n[yellow]Skipped {len(skipped)} pairing(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]") + + def _summary(self, pairs: List[Dict], unmatched: List[str]) -> None: + console.print( + f"\n[bold]Matched pairs:[/bold] {len(pairs)} " + f"[bold]Not yet upgraded:[/bold] {len(unmatched)}" + ) + + # --- dry run ----------------------------------------------------------- + + def preview(self) -> None: + console.print( + f"[bold cyan]Dry run (read-only)[/bold cyan] — root={self.root} " + f"lossy={','.join(self.lossy_exts)} lossless={','.join(self.lossless_exts)} " + f"show={self.show}\n" + ) + pairs, unmatched, skipped = self.compute() + if self.show in ("matched", "both") and pairs: + self._print_pairs(pairs) + if self.show in ("unmatched", "both"): + self._print_unmatched(unmatched) + self._print_skipped(skipped) + self._summary(pairs, unmatched) + if pairs: + console.print( + "\n[dim]Dry run — no files modified. Re-run with --write to apply.[/dim]" + ) + else: + console.print("[bold green]✔ No lossy→lossless pairs to copy.") + + # --- write ------------------------------------------------------------- + + def _backup_dir(self) -> str: + return get_tag_backup_dir() or str(PROJECT_ROOT / "lossless-tag-backups") + + def _v2_version(self, lossy: str) -> int: + """ID3 major version to write: 3 (v2.3, default) unless --mirror-version, + in which case mirror the source's version (falling back to 3).""" + if not self.mirror_version: + return 3 + try: + ver = ID3(lossy).version + return ver[1] if ver and ver[1] in (3, 4) else 3 + except Exception: # noqa: BLE001 - fall back to the safe default + return 3 + + def apply(self) -> None: + pairs, unmatched, skipped = self.compute() + if self.show in ("unmatched", "both"): + self._print_unmatched(unmatched) + self._print_skipped(skipped) + n = len(pairs) + if n == 0: + logger.info("[green]Nothing to copy — no lossy→lossless pairs found.") + return + + self._print_pairs(pairs) + backup_dir = self._backup_dir() + extras = [] + if self.remove_name: + extras.append("remove the AIFF NAME chunk") + else: + extras.append("set the AIFF NAME chunk to the copied title") + if self.delete_lossy: + extras.append("[red]delete the lossy source[/red]") + console.print( + f"\n[bold]About to copy tags onto [red]{n}[/red] lossless file(s)[/bold] " + f"and {', '.join(extras)}.\n" + f" Lossless originals are backed up under: {backup_dir}" + ) + if not confirm_destructive( + f"This overwrites the ID3 tags of {n} lossless file(s)" + + (" and deletes the matched lossy source(s)" if self.delete_lossy else "") + + ".", + "WRITE-TAGS", + title="Write Lossless Tags", + ): + logger.info("[yellow]Aborted — confirmation token did not match.") + return + + ok = 0 + deleted = 0 + changed_dirs: Set[str] = set() + failed: List[Tuple[Dict, str]] = [] + with progress_instance() as progress: + task = progress.add_task("", total=n) + for p in pairs: + progress.update( + task, + advance=1, + description=f'[cyan]Copying → "{os.path.basename(p["lossless"])}"...', + ) + try: + self._copy_pair(p, backup_dir) + ok += 1 + changed_dirs.add(os.path.dirname(os.path.realpath(p["lossless"]))) + if self.delete_lossy: + deleted += 1 + except Exception as e: # noqa: BLE001 - report and continue + failed.append((p, str(e))) + + console.print( + f"[bold green]✔ Copied tags onto {ok}/{n} lossless file(s).[/bold green]" + + (f" Deleted {deleted} lossy source(s)." if self.delete_lossy else "") + ) + for p, err in failed: + logger.error(f"[red]Failed: {p['lossless']}: {err}") + + if ok: + self._after_write(changed_dirs) + + 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 + is only removed once every prior step has succeeded.""" + lossy = pair["lossy"] + aiff = os.path.realpath(pair["lossless"]) + + # 1. Pristine backup BEFORE any mutation (mutagen.save writes in place). + backup = os.path.join(backup_dir, aiff.lstrip("/")) + os.makedirs(os.path.dirname(backup), exist_ok=True) + shutil.copy2(aiff, backup) + + # 2. Wholesale ID3 copy (leaves SSND/NAME chunks intact). + new_title = copy_id3_wholesale(lossy, aiff, v2_version=self._v2_version(lossy)) + + # 3. Align the native NAME chunk so Plex shows the new title (or strip it). + rewrite_name(aiff, None if self.remove_name else new_title, None) + + # 4. Only now is it safe to remove the redundant lossy source. + if self.delete_lossy: + os.remove(lossy) + + def _after_write(self, changed_dirs: Set[str]) -> None: + if not self.refresh: + console.print( + "\n[dim]Plex won't reflect the changes until it re-reads the files. " + "Re-run with --refresh-plex (needs PLEX_MEDIA_PATH_MAP), or trigger a " + "library scan of the affected folders in Plex.[/dim]" + ) + return + if not self.path_map: + logger.warning( + "[yellow]--refresh-plex needs PLEX_MEDIA_PATH_MAP to map host dirs " + "back to Plex paths — skipping the scan." + ) + return + from ..plex.resolvers.library import get_music_library + + library, _name = get_music_library() + console.print( + f"\n[cyan]Queuing a partial Plex scan of {len(changed_dirs)} folder(s)…[/cyan]" + ) + for host_dir in sorted(changed_dirs): + container = resolve_container_path(host_dir, self.path_map) + if container is None: + logger.warning( + f"[yellow]No PLEX_MEDIA_PATH_MAP prefix for {host_dir} — skipped." + ) + continue + try: + library.update(path=container) + console.print(f" [green]scan queued[/green] {container}") + except Exception as e: # noqa: BLE001 + logger.warning(f"[yellow]Scan failed for {container}: {e}") diff --git a/src/rekordbox2plex/config.py b/src/rekordbox2plex/config.py index bf56736..f8c9de5 100644 --- a/src/rekordbox2plex/config.py +++ b/src/rekordbox2plex/config.py @@ -151,6 +151,75 @@ def get_aiff_backup_dir() -> Optional[str]: return getattr(get_args(), "backup_dir", None) or os.getenv("AIFF_BACKUP_DIR") +# --- lossless-tags subcommand ------------------------------------------------- + + +def get_music_root() -> Optional[str]: + """`lossless-tags`: filesystem root to walk for lossy/lossless pairs. CLI + --root wins, then MUSIC_ROOT. None means unset (the action errors).""" + return getattr(get_args(), "root", None) or os.getenv("MUSIC_ROOT") + + +def _split_exts(raw: Optional[str], default: tuple[str, ...]) -> tuple[str, ...]: + """Normalise a comma-separated extension list into a lowercased, dot-prefixed + tuple (e.g. 'mp3, .M4A' → ('.mp3', '.m4a')). Empty/unset → default.""" + if not raw: + return default + out = [] + for part in raw.split(","): + e = part.strip().lower() + if not e: + continue + out.append(e if e.startswith(".") else "." + e) + return tuple(out) or default + + +def get_lossy_exts() -> tuple[str, ...]: + """`lossless-tags`: extensions treated as lossy sources (default '.mp3').""" + return _split_exts(getattr(get_args(), "lossy_exts", None), (".mp3",)) + + +def get_lossless_exts() -> tuple[str, ...]: + """`lossless-tags`: extensions treated as lossless targets (default AIFF). + Only AIFF/AIFF-C are supported for the wholesale ID3 copy; FLAC (Vorbis + comments) and WAV are intentionally excluded.""" + return _split_exts(getattr(get_args(), "lossless_exts", None), (".aiff", ".aif")) + + +def should_delete_lossy() -> bool: + """`lossless-tags`: delete the lossy source after a fully successful copy.""" + return getattr(get_args(), "delete_lossy", False) + + +def should_mirror_id3_version() -> bool: + """`lossless-tags`: save ID3 as the source's major version instead of forcing + v2.3 (the Plex/Rekordbox-friendly default).""" + return getattr(get_args(), "mirror_version", False) + + +def should_ignore_case() -> bool: + """`lossless-tags`: match basenames case-insensitively (default: exact, to + mirror the case-sensitive host filesystem).""" + return getattr(get_args(), "ignore_case", False) + + +def get_show_mode() -> str: + """`lossless-tags`: which lossy files the dry-run lists — 'matched' (default, + those with a lossless sibling), 'unmatched' (not yet upgraded), or 'both'.""" + return getattr(get_args(), "show", "matched") + + +def get_tag_backup_dir() -> Optional[str]: + """`lossless-tags`: where lossless originals are backed up before the ID3 + overwrite. CLI --backup-dir wins, then TAG_BACKUP_DIR; None → action default.""" + return getattr(get_args(), "backup_dir", None) or os.getenv("TAG_BACKUP_DIR") + + +def get_tag_copy_limit() -> Optional[int]: + """`lossless-tags`: process at most N pairs this run (None = no cap).""" + return getattr(get_args(), "limit", None) + + # --- artist-images subcommand ------------------------------------------------- diff --git a/src/rekordbox2plex/utils/helpers.py b/src/rekordbox2plex/utils/helpers.py index fe6a607..bcf02b2 100644 --- a/src/rekordbox2plex/utils/helpers.py +++ b/src/rekordbox2plex/utils/helpers.py @@ -341,4 +341,97 @@ def parse_script_arguments() -> argparse.Namespace: action="store_true", help="Bypass the container-stopped guard (only for scratch-copy testing).", ) + + # rekordbox2plex lossless-tags --root DIR [--write] [--delete-lossy] ... + lossless_tags = subparsers.add_parser( + "lossless-tags", + parents=[common], + help="Copy ID3 tags from a lossy file onto a same-named lossless " + "replacement (AIFF) in the same folder (read-only unless --write).", + ) + lossless_tags.add_argument( + "--root", + default=None, + metavar="DIR", + help="Music root to walk for lossy/lossless pairs (env MUSIC_ROOT).", + ) + lossless_tags.add_argument( + "--dry-run", + action="store_true", + help="Preview the tag diff per matched pair without writing " + "(also the default; overrides --write).", + ) + lossless_tags.add_argument( + "--write", + action="store_true", + help="Copy the tags onto the lossless files (requires the WRITE-TAGS token).", + ) + lossless_tags.add_argument( + "--show", + choices=["matched", "unmatched", "both"], + default="matched", + help="Which lossy files to list: matched (have a lossless sibling; " + "default), unmatched (not yet upgraded), or both.", + ) + lossless_tags.add_argument( + "--lossy-exts", + dest="lossy_exts", + default=None, + metavar="EXTS", + help="Comma-separated lossy source extensions (default '.mp3').", + ) + lossless_tags.add_argument( + "--lossless-exts", + dest="lossless_exts", + default=None, + metavar="EXTS", + help="Comma-separated lossless target extensions (default '.aiff,.aif'; " + "only AIFF/AIFF-C are supported — FLAC/WAV are skipped).", + ) + lossless_tags.add_argument( + "--remove-name", + action="store_true", + dest="remove_name", + help="Strip the AIFF NAME chunk instead of setting it to the copied title.", + ) + lossless_tags.add_argument( + "--refresh-plex", + action="store_true", + dest="refresh_plex", + help="After writing, trigger a partial Plex scan of the affected " + "directories (needs PLEX_MEDIA_PATH_MAP + Plex API connection).", + ) + lossless_tags.add_argument( + "--delete-lossy", + action="store_true", + dest="delete_lossy", + help="Delete the lossy source after a fully successful copy " + "(default: leave it for you to remove manually).", + ) + lossless_tags.add_argument( + "--mirror-version", + action="store_true", + dest="mirror_version", + help="Save ID3 as the source's major version instead of forcing v2.3.", + ) + lossless_tags.add_argument( + "--ignore-case", + action="store_true", + dest="ignore_case", + help="Match basenames case-insensitively (default: exact match).", + ) + lossless_tags.add_argument( + "--limit", + default=None, + type=int, + metavar="N", + help="Process at most N matched pairs this run.", + ) + lossless_tags.add_argument( + "--backup-dir", + default=None, + dest="backup_dir", + help="Directory to back up lossless originals before the ID3 overwrite " + "(default: ./lossless-tag-backups).", + ) return parser.parse_args() diff --git a/src/rekordbox2plex/utils/id3_tags.py b/src/rekordbox2plex/utils/id3_tags.py new file mode 100644 index 0000000..140b70e --- /dev/null +++ b/src/rekordbox2plex/utils/id3_tags.py @@ -0,0 +1,91 @@ +"""Wholesale ID3 copy from a lossy source onto a lossless ID3-carrying target. + +The ``lossless-tags`` command replaces a leftover lossy file (MP3) with a +lossless version (AIFF) of the same basename, but the freshly downloaded +lossless file usually has poor/no tags. Both MP3 and AIFF carry ID3v2, so we +copy the **entire** ID3 tag — every frame, including APIC artwork — from the +lossy file onto the lossless one, so the curated metadata survives the swap. +Tags remain the source of truth (see CLAUDE.md); this edits the file on disk, +never the Plex API. + +mutagen's ``AIFF.save()`` is surgical: it rewrites only the ``ID3 `` chunk and +leaves the native ``NAME`` chunk and the audio (``SSND``) bytes intact. The +caller is responsible for backing the target up first (mutagen saves in place) +and for the subsequent ``NAME``-chunk fix via ``utils.aiff_chunks.rewrite_name``. +""" + +from typing import Dict, Optional + +import mutagen +from mutagen.aiff import AIFF +from mutagen.id3 import ID3 + +# Human-readable ID3 text frames surfaced in the dry-run diff table. +_DIFF_FRAMES = { + "TIT2": "title", + "TPE1": "artist", + "TALB": "album", + "TPE2": "albumartist", + "TCON": "genre", + "TRCK": "track", +} + + +def read_id3_fields(path: str) -> Dict[str, object]: + """Read a small, human-readable subset of ID3 text frames for diff display. + + Works for both MP3 (ID3 at the file head) and AIFF (ID3 inside the ``ID3 `` + chunk) via ``mutagen.File``. Returns ``{}`` if the file has no ID3 tag (or + can't be parsed). Never raises. Includes a boolean ``has_artwork`` flag (any + APIC frame present).""" + tags = None + try: + audio = mutagen.File(path) + tags = getattr(audio, "tags", None) + except Exception: # noqa: BLE001 - unparseable; diff is best-effort + tags = None + if not tags: + # Fallback for files mutagen.File can't type (e.g. a tag-only MP3 with no + # decodable audio frames): read the bare ID3 tag from the file head. + try: + tags = ID3(path) + except Exception: # noqa: BLE001 - no ID3 tag at all + return {} + out: Dict[str, object] = {} + for fid, label in _DIFF_FRAMES.items(): + frame = tags.get(fid) + text = getattr(frame, "text", None) + out[label] = str(text[0]) if text else None + out["has_artwork"] = bool(tags.getall("APIC")) + return out + + +def copy_id3_wholesale(src_path: str, dst_path: str, v2_version: int = 3) -> str: + """Replace ``dst``'s entire ID3 tag with ``src``'s (all frames incl. APIC). + + ``src`` is a lossy ID3-bearing file (MP3); ``dst`` an AIFF/AIFF-C. Any + pre-existing ID3 on ``dst`` is cleared first (wholesale replace). Saves in + place — the caller MUST snapshot ``dst`` beforehand. Returns the new ``TIT2`` + text ("" if none) so the caller can align the AIFF ``NAME`` chunk. + + ``v2_version`` is the ID3v2 minor version to write (3 = v2.3, the + Plex/Rekordbox-friendly default; 4 = v2.4). Raises ``ID3NoHeaderError`` when + ``src`` has no ID3 tag (the caller skips + warns). Leaves the AIFF's + ``SSND``/``COMM``/``NAME`` chunks untouched.""" + if v2_version not in (3, 4): + raise ValueError("v2_version must be 3 or 4") + src = ID3(src_path) # raises ID3NoHeaderError if the lossy file is untagged + + dst = AIFF(dst_path) + if dst.tags is None: + dst.add_tags() # initialise an empty ID3; only legal when none exists + tags = dst.tags + assert tags is not None # add_tags() guarantees this; narrows for the type checker + tags.clear() # wholesale: drop any stale ID3 already on the lossless file + for frame in src.values(): + tags.add(frame) # text frames + APIC artwork, keyed by HashKey + dst.save(v2_version=v2_version) # surgical ID3-chunk write; mutagen downgrades + + tit2 = tags.get("TIT2") + text: Optional[list] = getattr(tit2, "text", None) + return str(text[0]) if text else "" diff --git a/src/rekordbox2plex/utils/media_paths.py b/src/rekordbox2plex/utils/media_paths.py index 14c6065..2773bdd 100644 --- a/src/rekordbox2plex/utils/media_paths.py +++ b/src/rekordbox2plex/utils/media_paths.py @@ -46,3 +46,18 @@ def resolve_host_path(plex_path: str, mapping: PathMap) -> Optional[str]: if plex_path == container or plex_path.startswith(container + "/"): return host + plex_path[len(container) :] return None + + +def resolve_container_path(host_path: str, mapping: PathMap) -> Optional[str]: + """Return the Plex *container* path for a host filesystem path, or None if no + mapping prefix matches — the inverse of ``resolve_host_path``. Used to scope a + Plex partial scan (``LibrarySection.update(path=…)``) to a host directory. + + Longest *host* prefix wins (the map is stored longest-container-first, so we + re-sort by host length here).""" + for host, container in sorted( + ((h, c) for c, h in mapping), key=lambda p: len(p[0]), reverse=True + ): + if host_path == host or host_path.startswith(host + "/"): + return container + host_path[len(host) :] + return None diff --git a/tests/LosslessTagCopy_test.py b/tests/LosslessTagCopy_test.py new file mode 100644 index 0000000..ff8b8fb --- /dev/null +++ b/tests/LosslessTagCopy_test.py @@ -0,0 +1,218 @@ +import argparse +import hashlib +import struct + +import pytest +from mutagen.aiff import AIFF +from mutagen.id3 import ID3, TIT2, TPE1 + +from rekordbox2plex import config +from rekordbox2plex.actions.LosslessTagCopy import LosslessTagCopy +from rekordbox2plex.utils.aiff_chunks import read_name + + +def _chunk(cid: bytes, data: bytes) -> bytes: + out = cid + struct.pack(">I", len(data)) + data + if len(data) & 1: + out += b"\x00" + return out + + +def _aiff_bytes(samples=b"\x01\x02\x03\x04" * 4, name_text=None) -> bytes: + sample_rate_80bit = bytes([0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]) + n_frames = len(samples) // 4 + comm = ( + struct.pack(">h", 2) + + struct.pack(">I", n_frames) + + struct.pack(">h", 16) + + sample_rate_80bit + ) + body = _chunk(b"COMM", comm) + if name_text is not None: + body += _chunk(b"NAME", name_text.encode("utf-8")) + body += _chunk(b"SSND", struct.pack(">I", 0) + struct.pack(">I", 0) + samples) + form = b"AIFF" + body + return b"FORM" + struct.pack(">I", len(form)) + form + + +def _write_aiff(path, name_text=None): + path.write_bytes(_aiff_bytes(name_text=name_text)) + return path + + +def _ssnd_md5(path): + data = path.read_bytes() + pos = 12 + while pos + 8 <= len(data): + cid = data[pos : pos + 4] + size = struct.unpack(">I", data[pos + 4 : pos + 8])[0] + total = 8 + size + (size & 1) + if cid == b"SSND": + return hashlib.md5(data[pos : pos + total]).hexdigest() + pos += total + return None + + +def _write_mp3(path, title="Title", artist="Artist"): + tags = ID3() + tags.add(TIT2(encoding=3, text=[title])) + tags.add(TPE1(encoding=3, text=[artist])) + tags.save(str(path)) + return path + + +def _set_args(root, **overrides): + defaults = dict( + command="lossless-tags", + root=str(root), + dry_run=True, + write=False, + show="matched", + lossy_exts=None, + lossless_exts=None, + remove_name=False, + refresh_plex=False, + delete_lossy=False, + mirror_version=False, + ignore_case=False, + limit=None, + backup_dir=None, + verbose=0, + ) + defaults.update(overrides) + config.set_args(argparse.Namespace(**defaults)) + + +def test_matched_pair_recurses_and_reads_incoming_tags(tmp_path): + sub = tmp_path / "Artist - Album" + sub.mkdir() + _write_mp3(sub / "song.mp3", title="Real Title", artist="Fred V") + _write_aiff(sub / "song.aiff") + _set_args(tmp_path) + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert len(pairs) == 1 + assert not unmatched and not skipped + p = pairs[0] + assert p["lossy_fields"]["title"] == "Real Title" + assert p["overwrite"] is False # the AIFF had no tags + + +def test_lossy_without_sibling_is_unmatched(tmp_path): + _write_mp3(tmp_path / "orphan.mp3") + _set_args(tmp_path) + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert not pairs and not skipped + assert unmatched == [str(tmp_path / "orphan.mp3")] + + +def test_more_than_one_lossless_sibling_is_skipped(tmp_path): + _write_mp3(tmp_path / "dup.mp3") + _write_aiff(tmp_path / "dup.aiff") + _write_aiff(tmp_path / "dup.aif") + _set_args(tmp_path) + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert not pairs and not unmatched + assert len(skipped) == 1 + assert ">1 lossless sibling" in skipped[0][1] + + +def test_lossy_without_id3_is_skipped(tmp_path): + (tmp_path / "bare.mp3").write_bytes(b"no id3 here" * 8) + _write_aiff(tmp_path / "bare.aiff") + _set_args(tmp_path) + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert not pairs + assert skipped and "no ID3 tag" in skipped[0][1] + + +def test_zero_byte_lossless_is_skipped(tmp_path): + _write_mp3(tmp_path / "z.mp3") + (tmp_path / "z.aiff").write_bytes(b"") + _set_args(tmp_path) + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert not pairs + assert skipped and "zero-byte" in skipped[0][1] + + +def test_unsupported_lossless_target_is_skipped(tmp_path): + _write_mp3(tmp_path / "t.mp3") + (tmp_path / "t.flac").write_bytes(b"fLaC fake") + _set_args(tmp_path, lossless_exts=".aiff,.flac") + + pairs, unmatched, skipped = LosslessTagCopy().compute() + + assert not pairs + assert skipped and "unsupported lossless target" in skipped[0][1] + + +@pytest.mark.parametrize( + "ignore_case,expect_pairs", [(False, 0), (True, 1)], ids=["exact", "ignore-case"] +) +def test_basename_case_sensitivity(tmp_path, ignore_case, expect_pairs): + _write_mp3(tmp_path / "Song.mp3") + _write_aiff(tmp_path / "song.aiff") + _set_args(tmp_path, ignore_case=ignore_case) + + pairs, unmatched, _skipped = LosslessTagCopy().compute() + + assert len(pairs) == expect_pairs + # The lossy file is unmatched in the exact-match case. + assert len(unmatched) == (1 - expect_pairs) + + +def test_copy_pair_write_path_backs_up_aligns_name_and_deletes_lossy(tmp_path): + """End-to-end of the actual mutation: backup → wholesale ID3 copy → NAME-chunk + alignment → lossy delete (bypassing the interactive confirmation).""" + mp3 = _write_mp3(tmp_path / "song.mp3", title="Real Title", artist="Fred V") + aiff = _write_aiff(tmp_path / "song.aiff", name_text="Stale NAME") + ssnd_before = _ssnd_md5(aiff) + _set_args(tmp_path, write=True, dry_run=False, delete_lossy=True) + + action = LosslessTagCopy() + pairs, _unmatched, _skipped = action.compute() + assert len(pairs) == 1 + backup_dir = str(tmp_path / "backups") + action._copy_pair(pairs[0], backup_dir) + + tags = AIFF(str(aiff)).tags + assert str(tags["TIT2"].text[0]) == "Real Title" # tags copied from the MP3 + assert str(tags["TPE1"].text[0]) == "Fred V" + assert read_name(str(aiff)) == "Real Title" # NAME chunk aligned to new title + assert _ssnd_md5(aiff) == ssnd_before # audio byte-identical + assert not mp3.exists() # lossy source deleted after success + assert (tmp_path / "backups" / str(aiff).lstrip("/")).exists() # pristine backup + + +def test_copy_pair_remove_name_strips_chunk_and_keeps_lossy(tmp_path): + mp3 = _write_mp3(tmp_path / "t.mp3", title="New") + aiff = _write_aiff(tmp_path / "t.aiff", name_text="Stale NAME") + _set_args(tmp_path, write=True, dry_run=False, remove_name=True) + + action = LosslessTagCopy() + pairs, _u, _s = action.compute() + action._copy_pair(pairs[0], str(tmp_path / "backups")) + + assert read_name(str(aiff)) is None # NAME chunk stripped → Plex falls back to ID3 + assert str(AIFF(str(aiff)).tags["TIT2"].text[0]) == "New" + assert mp3.exists() # lossy kept when --delete-lossy not given + + +def test_limit_caps_matched_pairs(tmp_path): + for i in range(3): + _write_mp3(tmp_path / f"s{i}.mp3") + _write_aiff(tmp_path / f"s{i}.aiff") + _set_args(tmp_path, limit=2) + + pairs, _unmatched, _skipped = LosslessTagCopy().compute() + + assert len(pairs) == 2 diff --git a/tests/id3_tags_test.py b/tests/id3_tags_test.py new file mode 100644 index 0000000..0a4ea46 --- /dev/null +++ b/tests/id3_tags_test.py @@ -0,0 +1,113 @@ +import hashlib +import struct + +from mutagen.aiff import AIFF +from mutagen.id3 import APIC, ID3, TALB, TCON, TIT2, TPE1 + +from rekordbox2plex.utils.aiff_chunks import read_name +from rekordbox2plex.utils.id3_tags import copy_id3_wholesale, read_id3_fields + +# 1x1 transparent PNG — stand-in for embedded artwork (APIC). +_PNG = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01" + b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def _chunk(cid: bytes, data: bytes) -> bytes: + out = cid + struct.pack(">I", len(data)) + data + if len(data) & 1: # AIFF chunks are word-aligned + out += b"\x00" + return out + + +def _build_aiff(name_text=None, samples=b"\x01\x02\x03\x04" * 4) -> bytes: + """A minimal, mutagen-parseable AIFF: COMM + optional NAME + SSND.""" + sample_rate_80bit = bytes([0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]) # 44100 Hz + n_frames = len(samples) // (2 * 2) # 2 channels, 16-bit + comm = ( + struct.pack(">h", 2) + + struct.pack(">I", n_frames) + + struct.pack(">h", 16) + + sample_rate_80bit + ) + body = _chunk(b"COMM", comm) + if name_text is not None: + body += _chunk(b"NAME", name_text.encode("utf-8")) + body += _chunk(b"SSND", struct.pack(">I", 0) + struct.pack(">I", 0) + samples) + form = b"AIFF" + body + return b"FORM" + struct.pack(">I", len(form)) + form + + +def _ssnd_md5(path: str): + with open(path, "rb") as f: + data = f.read() + pos = 12 + while pos + 8 <= len(data): + cid = data[pos : pos + 4] + size = struct.unpack(">I", data[pos + 4 : pos + 8])[0] + total = 8 + size + (size & 1) + if cid == b"SSND": + return hashlib.md5(data[pos : pos + total]).hexdigest() + pos += total + return None + + +def _make_mp3(path, title="Title", artist="Artist", album="Album", art=True): + """A tag-only 'MP3' (ID3 head, no audio frames) — enough for the copy.""" + tags = ID3() + tags.add(TIT2(encoding=3, text=[title])) + tags.add(TPE1(encoding=3, text=[artist])) + tags.add(TALB(encoding=3, text=[album])) + tags.add(TCON(encoding=3, text=["Drum & Bass"])) + if art: + tags.add(APIC(encoding=3, mime="image/png", type=3, desc="", data=_PNG)) + tags.save(str(path)) + return path + + +def test_copy_id3_wholesale_transfers_all_frames_and_preserves_audio(tmp_path): + mp3 = _make_mp3(tmp_path / "track.mp3", title="Real Title", artist="Fred V") + aiff = tmp_path / "track.aiff" + aiff.write_bytes(_build_aiff(name_text="Stale NAME")) + # Give the AIFF some pre-existing (wrong) ID3 to prove a wholesale replace. + pre = AIFF(str(aiff)) + pre.add_tags() + pre.tags.add(TIT2(encoding=3, text=["WRONG"])) + pre.save() + ssnd_before = _ssnd_md5(str(aiff)) + + new_title = copy_id3_wholesale(str(mp3), str(aiff), v2_version=3) + + assert new_title == "Real Title" + tags = AIFF(str(aiff)).tags + assert str(tags["TIT2"].text[0]) == "Real Title" + assert str(tags["TPE1"].text[0]) == "Fred V" + assert str(tags["TALB"].text[0]) == "Album" + assert tags.getall("APIC") # artwork carried over + assert tags.version[1] == 3 # saved as ID3 v2.3 + assert _ssnd_md5(str(aiff)) == ssnd_before # audio byte-identical + assert read_name(str(aiff)) == "Stale NAME" # NAME chunk untouched by the copy + + +def test_read_id3_fields_reads_mp3_and_aiff(tmp_path): + mp3 = _make_mp3(tmp_path / "x.mp3", title="T", artist="A", album="B") + fields = read_id3_fields(str(mp3)) + assert fields["title"] == "T" + assert fields["artist"] == "A" + assert fields["album"] == "B" + assert fields["has_artwork"] is True + + aiff = tmp_path / "x.aiff" + aiff.write_bytes(_build_aiff()) + copy_id3_wholesale(str(mp3), str(aiff)) + aiff_fields = read_id3_fields(str(aiff)) + assert aiff_fields["title"] == "T" + assert aiff_fields["has_artwork"] is True + + +def test_read_id3_fields_empty_when_untagged(tmp_path): + bare = tmp_path / "untagged.mp3" + bare.write_bytes(b"not an id3 tag at all" * 8) + assert read_id3_fields(str(bare)) == {}