# 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)` wraps `QMediaPlayer`/`QAudioOutput`. It is deliberately **context-agnostic** (knows a queue + shuffle walk order, not which view started playback). It exposes `track_changed`/`playing_changed`/ `position_changed`/`duration_changed` signals consumed by the transport, MPRIS, and the visualizer (a `QAudioBufferOutput` tee feeds PCM to the spectrum bars). 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`. ## 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`). Nothing else may move or rewrite 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). - `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).