7.7 KiB
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
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:
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-xmlruns a headless import and exits; otherwiserun_gui()resolves Syncthing conflicts, loads the library, buildsPreferences/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) withto_dict/from_dictround-tripping.PlaylistTypeisREGULAR | FOLDER | SMART | SYSTEM. Per-playlistPlaylistSettingsholds visible columns / sort column / widths. Playlists are identified by an 8-char hexpersistent_id(folder containment viaparent_persistent_id). -
lintunes/storage/json_storage.py— the library is multiple files in the data dir:library.json(all tracks),library_metadata.json, and oneplaylists/<persistent_id>.jsonper playlist. Writes are atomic (*.json.tmp→ rename).storage/conflict_resolver.pymerges 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 theLibrary, is the single funnel for all mutations, and persists them debounced (3 s) with per-area dirty tracking (a play-count bump rewrites onlylibrary.json; a playlist edit rewrites only that playlist file). User edits go throughundo_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)wrapsQMediaPlayer/QAudioOutput. It is deliberately context-agnostic (knows a queue + shuffle walk order, not which view started playback). It exposestrack_changed/playing_changed/position_changed/duration_changedsignals consumed by the transport, MPRIS, and the visualizer (aQAudioBufferOutputtee feeds PCM to the spectrum bars). Playback context ("library" / "playlist:") is tracked inMainWindow, not the player. -
lintunes/gui/—main_window.pyassembles a topTransportBarover a horizontalQSplitter(SidebarPanel| stackedLibraryView/PlaylistView).track_table.pyis the shared track grid (drag/drop, copy/paste, drop indicator).playlist_ops.py::add_tracks_with_dup_checkis the single funnel for every add-to-playlist path.theme.pyapplies the palette (highlight colors, UI scales) from prefs. -
lintunes/importers/itunes_importer.py— parses the iTunes XML plist. Remaps Macfile:///Volumes/...paths to the local--music-rootwith 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 emitschanged;MainWindow._on_prefs_changedre-applies theme/metrics live. -
lintunes/mpris.py— registersorg.mpris.MediaPlayer2.lintunesover D-Bus so the desktop's media keys / now-playing popup control playback. Spacebar and arrow keys are handled locally viaMainWindow.eventFilter.
Conventions & gotchas
- Tests are organized as
tests/test_roundN.py— each development round adds a newtest_roundN.pyalongside the topical files (test_models.py,test_itunes_importer.py, etc.). New feature work follows the same pattern. - Keep
Playerandtrack_tablemanager-free where they already are — cross-cutting data is injected via callbacks/signals (e.g.track_tabletakes aplaylists_for_trackcallback rather than importing the manager). - Qt/Wayland gotchas (GNOME/Mutter):
QDrag.setPixmap/setDragCursor/QCursor.pos()are unreliable during a drag —gui/drag_ghost.pypaints its own child-widget overlay instead.QAudioOutputmust not be constructed before aQMainWindowexists (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 insideLibraryManager.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 thelocationnewest-wins merge inconflict_resolver). Nothing else may move or rewrite music files. The library JSON is the source of truth for everything else. - Versioning & self-update:
__version__inlintunes/__init__.pyis the single source of truth (setup.pyregex-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) checksoriginshortly after launch and every 4 h, and a click runsgit pull --ff-onlythen re-execs the app — pushingmasteris effectively releasing to the other machines (a new pip dependency still needs a manualpip 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--writeto mutate files.- Not under version control until recently — the
*~files are editor backups (gitignored).data/anditunes-test-library/are gitignored (the user's real library + large import fixture).