Files
lintunes/CLAUDE.md
T
travandClaude Opus 5 800e8ffb5d v0.11.0: web mixes pick their own color
The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.

The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.

The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.

Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
2026-08-22 15:14:36 -04:00

268 lines
17 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
LinTunes is an iTunes-replacement music library manager and player for Linux,
built with PyQt6 / Qt Multimedia. It imports an iTunes 12 library, stores the
library as plain JSON (syncable via Syncthing), and reproduces the iTunes UI
(left sidebar + right playlist view, column browser, customizable per-playlist
columns). `spec.md` is the original design brief; `TASKS.md` is the live backlog
and `tasks-done.md` records completed work.
## Commands
```sh
pip install -e . # install deps (PyQt6, mutagen, numpy, requests)
python3 -m lintunes.main # run the app straight from the checkout
python3 -m pytest # run the whole test suite
python3 -m pytest tests/test_round9.py # one test file
python3 -m pytest tests/test_round9.py::test_name # one test
```
The app needs a `--data-dir`; config persists to `~/.config/lintunes/config.json`.
One-time iTunes import:
```sh
lintunes --import-xml "iTunes Library.xml" \
--music-root "/path/to/iTunes Media" \
--data-dir /path/to/library-data --save-config
```
Runtime needs FFmpeg codecs for Qt Multimedia (`qt6-qtmultimedia` w/ ffmpeg).
Tests run headless via the `qapp` fixture in `tests/conftest.py`
(`QT_QPA_PLATFORM=offscreen`).
## Architecture
**Data flows in one direction through three layers:** storage (JSON on disk) →
model (`Library`/`Track`/`Playlist` dataclasses) → `LibraryManager` (mutations +
persistence) → GUI (Qt widgets that read the manager and connect to its signals).
- **`lintunes/main.py`** — CLI entry. `--import-xml` runs a headless import and
exits; otherwise `run_gui()` resolves Syncthing conflicts, loads the library,
builds `Preferences`/`LibraryManager`/`LastFm`/`MainWindow`, wires MPRIS, and
installs the window as a global event filter (for media keys).
- **`lintunes/models/`** — pure dataclasses (`track.py`, `playlist.py`,
`library.py`) with `to_dict`/`from_dict` round-tripping. `PlaylistType` is
`REGULAR | FOLDER | SMART | SYSTEM`. Per-playlist `PlaylistSettings` holds
visible columns / sort column / widths. Playlists are identified by an 8-char
hex `persistent_id` (folder containment via `parent_persistent_id`).
- **`lintunes/storage/json_storage.py`** — the library is **multiple files** in
the data dir: `library.json` (all tracks), `library_metadata.json`, one
`playlists/<persistent_id>.json` per playlist, and one
`plays/<machine-id>.json` per machine. Writes are atomic (`*.json.tmp`
→ rename). **`storage/conflict_resolver.py`** merges Syncthing
`*.sync-conflict-*` files on startup: play counts take the max, edited fields
take the newest, playlist membership takes the union. Since Round 39 that
union is **anchor-based** (`merge_track_order`): a track only one copy has is
re-inserted after the nearest track both share, not appended at the tail, so
a middle insert stays in the middle. Whose order wins is decided by
`Playlist.date_modified` — bumped *only* in
`LibraryManager._set_track_ids` — not by the file's mtime, which moves for
cosmetic reasons. `library_manager._reconcile_playlist` uses the same helper
for the live-reload path.
- **`lintunes/storage/play_journal.py`** — why play counts can't conflict. Since
Round 38 `library.json` holds only a **base** count and each machine owns
`plays/<machine-id>.json` with *its own* per-track totals; effective count =
base + the sum of every journal. Only the owner ever writes its journal, so
two machines never touch the same file — and finishing a track no longer
rewrites 15 MB, which is what handed Syncthing a conflict once per song.
Totals, not an append log, so there is no compaction step to double-count in.
`PlayJournal.load()` must be handed a library whose tracks still carry **base**
counts (the fresh load at startup, the `disk` copy inside `reload_from_disk`) —
folding an already-folded library promotes the effective count to base.
Mirror-image rule in `json_storage.save_tracks()`: it writes
`journal.base_fields(tid)`, never the `Track`'s effective count. Those two
places are the whole hazard; `tests/test_round38.py` pins both.
- **`lintunes/library_manager.py`** — `LibraryManager(QObject)` owns the
`Library`, is the single funnel for all mutations, and persists them
**debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites
only `library.json`; a playlist edit rewrites only that playlist file). User
edits go through `undo_stack` (Ctrl+Z); the internal `_apply_*`/`_set_*`
helpers do the real mutation + dirty-mark + signal and are reused by undo/redo
without recursing. Widgets react to its signals (`playlists_changed`,
`playlist_content_changed(pid)`, `track_updated(id)`, `track_fields_edited`).
- **`lintunes/player.py`** — `Player(QObject)` walks a queue through a swappable
**`PlaybackSink`**. Player owns the queue, shuffle walk, per-track start/stop
times and the play-count/scrobble bookkeeping; a sink owns only "make this file
come out of something, and report where it's up to". `LocalSink` (the
`QMediaPlayer`/`QAudioOutput` pipeline, with the `QAudioBufferOutput` PCM tee
that feeds the visualizer) **stays in `player.py`** — the Player tests stub Qt
Multimedia with `patch.multiple(player_module, QMediaPlayer=..., …)`, so those
names must resolve in this module. `cast/sink.py::CastSink` is the other
implementation. `set_sink()` carries the current track, position and
playing-state across a swap and deliberately does *not* re-emit
`track_changed` (that would double-scrobble the same song).
Player is also deliberately **context-agnostic**: playback *context*
("library" / "playlist:<pid>") is tracked in `MainWindow`, not the player.
- **`lintunes/gui/`** — `main_window.py` assembles a top `TransportBar` over a
horizontal `QSplitter` (`SidebarPanel` | stacked `LibraryView`/`PlaylistView`).
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
for every add-to-playlist path. `theme.py` applies the palette (highlight
colors, UI scales) from prefs.
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
ext4). Imports user playlists, folders **and smart playlists** (criteria
parsed by `smart.parse_itunes_smart`; criteria it can't model are kept as a
static snapshot — `report.smart_unsupported`). System playlists are skipped
(`report.skipped_system`). Album art is read live from embedded ID3 tags
(`tagging.py`), never stored in the library JSON.
- **`lintunes/preferences.py`** — app settings in `<data_dir>/preferences.json`
(rides the same Syncthing share). `Preferences.set(key, value)` saves and emits
`changed`; `MainWindow._on_prefs_changed` re-applies theme/metrics live.
Because it is synced, **anything machine-specific belongs in `config.py`'s
`config.json` instead** (see `music_folder.py`). `now_playing_font` has three
states — `None` automatic (best available from `theme.NOW_PLAYING_FALLBACKS`),
`""` the app font, or an explicit family. Startup never prompts for it; the
row in Preferences is the only place it's chosen.
- **`lintunes/music_folder.py`** — where the organized tree lives *on this
machine*. The library stores it three ways (`models/library.py`):
`music_folder` absolute (what pre-0.10 code reads — kept forever as the shared
floor between versions), `music_folder_rel` relative to the data dir (the
portable one, same trick as `Track.location`), and `music_folder_set_at`, a
stamp so a merge prefers the newest *setting* rather than the newest *file*.
`resolve()` tries rel → legacy → the per-machine override in `config.json`,
and every candidate must **exist** — the override is consulted last so a
machine that once needed one isn't pinned to it forever. Two rules that bite:
`LibraryManager.set_music_folder` must call `mark_library_settings_dirty()`
(without it `reload_from_disk` reverts the change on the next sync tick), and
`organize_root()` can return `None`, which every caller must handle rather
than inventing a relative path — `MainWindow._music_import_dir` used to fall
back to `Path("Music")`, which resolved against a working directory GNOME's
dash doesn't set predictably.
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
so the desktop's media keys / now-playing popup control playback. Spacebar and
arrow keys are handled locally via `MainWindow.eventFilter`.
- **`lintunes/trash.py`** — freedesktop.org Trash spec 1.0, hand-rolled (no new
dep). The trash is **per-filesystem**: music usually lives on a mounted volume,
so the file belongs in `<topdir>/.Trash-<uid>` with a *topdir-relative*,
percent-encoded `Path=`, not in `~/.local/share/Trash` (which would be a
cross-device copy the file manager can't "Restore"). The `.trashinfo` is
created with `O_EXCL` **first** to claim the name atomically, then the file is
renamed in; a failed rename unlinks the info file so there's never a
half-trashed pair. Raises `TrashError` without touching the file, so a caller
can treat failure as "not deleted". Only `LibraryManager` calls it.
- **`lintunes/device_sync.py`** — one-way playlist sync to the Rabbit R1 (Device
menu). The Rabbit mounts via **MTP/gvfs** (a FUSE path under
`/run/user/<uid>/gvfs`), not mass storage — so plain file I/O, but never
copystat and never trust mtimes (diff by name+size). Sync owns exactly
`Music/<Playlist Name>/` on the device (creates/overwrites/deletes there,
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
only ever *reads* local library files.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers
and `build_m3u`: pure `plan_export()` first, then `ExportWorker` on a daemon
thread. Two destinations — a **folder** (files as `Artist - Title.ext` plus an
`.m3u`) or a **web mix** (`index.html` + `audios/` + hero image, from
`templates/`). **The manifest is written last**, so an interrupted export
never leaves a page or m3u naming files that aren't there. `web_support.py`
is the format gate, shaped like `cast/support.py`: deny-by-default on the
suffix with the iTunes `kind` breaking the `.m4a` tie. **Bitrate never
triggers a conversion** — only unplayability does — and every conversion
targets **FLAC**, so it can't cost a bit; DRM'd tracks are reported, never
attempted. ffmpeg is the *CLI binary* here (not Qt's ffmpeg backend), so it's
detected at runtime and its absence is offered as "export without them".
The templates ship a ~180-line dependency-free `player.js`/`player.css` that
replaced audio.js + jQuery (abandoned since 2012; jQuery was only ever glue —
audio.js never used it). `player-graphics.gif` must ship **byte-identical**:
it's an animated GIF whose loading frame is a spinner, not a flat sprite
sheet — which is why Round 41 recolors its pale glyphs to black with
`filter: brightness(0)` in CSS rather than editing the file. The one
user-chosen color (link/row hover backgrounds + the progress fill) travels as
a `:root { --accent }` custom property declared in `index.html`, so
`player.css` can read it while staying a verbatim `shutil.copyfile` — the page
is still the only rendered template. `exporter.normalize_accent` is a hard
gate, not politeness: the value lands raw inside a `<style>` block and
`string.Template` escapes nothing. The accent is **not** persisted, so an
untouched export still renders the original `#8c764a`. Templates are
registered in `setup.py` via `package_data`; loaded with
`importlib.resources` so an installed copy finds them.
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
ephemeral port for the life of a session and the device fetches the *original*
file itself (bit-exact, no transcode). URLs carry an opaque random token, never
a path, so traversal is structurally impossible; Range + HEAD are mandatory
(the device seeks by re-requesting ranges and won't report a duration without
them). A token resolves to an `_Asset` that is either a file on disk (audio)
or an in-memory blob (album art, which lives in tags rather than as its own
file); the two have **separate eviction rings** so a cover can't push out the
previous track's audio. Art is passed as `play_media(thumb=…)`, which
pychromecast folds into `metadata["images"]` — that's what a TV paints
full-screen. `support.py` is the format gate — ALAC, AIFF and protected AAC are
refused and Player skips them with a status-bar message. `discovery.py` wraps
`CastBrowser`; `sink.py` is the `PlaybackSink`; `controller.py` owns the
session and its own `SleepInhibitor`. **pychromecast is imported lazily**, never
at module scope, so the app still launches where the dep isn't installed yet.
While casting there is no local PCM, so the **visualizer panel doubles as the
cast indicator**: it shows the cast glyph instead of bars and a click there
stops casting (the brightness cycle is suppressed — it means nothing with no
bars). That is the only cast control in the transport bar. Position comes from
a 500 ms poll of `adjusted_current_time` (only trusted while PLAYING — it
creeps while paused).
## Conventions & gotchas
- **Tests are organized as `tests/test_roundN.py`** — each development round adds
a new `test_roundN.py` alongside the topical files (`test_models.py`,
`test_itunes_importer.py`, etc.). New feature work follows the same pattern.
- **Keep `Player` and `track_table` manager-free where they already are** —
cross-cutting data is injected via callbacks/signals (e.g. `track_table` takes
a `playlists_for_track` callback rather than importing the manager).
- **Cosmetic table settings must not look like edits.** The last column is
stretch-sized, so Qt re-fires `sectionResized` for it on every viewport width
change; `track_table._on_section_resized` ignores that section, or resizing
the window would rewrite the open playlist's JSON (and hand Syncthing a
conflict) purely for a width `apply_settings` overrides on load anyway.
- **Qt/Wayland gotchas (GNOME/Mutter):** `QDrag.setPixmap` / `setDragCursor` /
`QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its
own child-widget overlay instead. `QAudioOutput` must not be constructed before
a `QMainWindow` exists (Qt 6.10 deadlock). Some PyQt signal relays need explicit
types/lambdas.
- **Music files are only touched deliberately:** tag edits via `tagging.py`,
and — since Round 18 — artist/album_artist/album edits relocate the file
inside `LibraryManager.organize_root()` (`<music_folder>/Music`) to keep the
tree organized iTunes-style (`_maybe_move_file`; undoable; files outside the
root are never moved; the new path syncs cross-machine via the `location`
newest-wins merge in `conflict_resolver`). Since Round 33 the *only* other
path is an explicit user delete (`LibraryManager.delete_tracks`), which moves
the file to the desktop trash via `trash.py` — never `unlink`, so it stays
recoverable. Nothing else may move, rewrite or remove music files. The library
JSON is the source of truth for everything else.
- **Versioning & self-update:** `__version__` in `lintunes/__init__.py` is
the single source of truth (`setup.py` regex-reads it, never imports the
package). Claude bumps minor for feature rounds and patch for fix-only
rounds as part of each round's final commit; trav decides major bumps. The
status-bar version button (`gui/version_button.py` + `lintunes/updater.py`)
checks `origin` shortly after launch and every 4 h, and a click runs
`git pull --ff-only` then re-execs the app — pushing `master` is
effectively releasing to the other machines (a new pip dependency still
needs a manual `pip install -e .` there). **Every round ends with commit
AND push** (trav's standing request, 2026-07-03: both machines ride the
bleeding edge, sync as often as possible) — so never leave master in a
half-working state between commits you push.
- `scripts/` holds one-off maintenance tools (`audit_artwork.py`,
`recover_artwork.py`, `clear_computed_ratings.py`) run manually against a data
dir; most default to dry-run and need `--write` to mutate files.
- **Not under version control until recently** — the `*~` files are editor
backups (gitignored). `data/` and `itunes-test-library/` are gitignored (the
user's real library + large import fixture).