# 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/.json` per playlist, and one `plays/.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/.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:") 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 `/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 `/.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/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/`; **no m3u** — it's a folder you upload, not one you open in a player, so `plan.m3u_name` is `""` there). **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 `