# 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`, and one `playlists/.json` per playlist. 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. - **`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:") 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 only; smart and system playlists are currently skipped. Album art is read live from embedded ID3 tags (`tagging.py`), never stored in the library JSON. - **`lintunes/preferences.py`** — app settings in `/preferences.json` (rides the same Syncthing share). `Preferences.set(key, value)` saves and emits `changed`; `MainWindow._on_prefs_changed` re-applies theme/metrics live. - **`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 `/.Trash-` 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//gvfs`), not mass storage — so plain file I/O, but never copystat and never trust mtimes (diff by name+size). Sync owns exactly `Music//` 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/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). - **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`) 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).