135 lines
7.5 KiB
Markdown
135 lines
7.5 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`, and one
|
|
`playlists/<persistent_id>.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:<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 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 `<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.
|
|
|
|
- **`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_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`). 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).
|