diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8c3428b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# 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. +- **Never move or rewrite the user's music files** except deliberate tag edits + via `tagging.py`. The library JSON is the source of truth for everything else. +- `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).