Files
lintunes/CLAUDE.md

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-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.pyLibraryManager(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.pyPlayer(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 <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). 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 --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).