Files
lintunes/CLAUDE.md
T
travandClaude Opus 5 574e476dc3 v0.9.1: playlist merges stop losing your track order
What scrambled `a nissa one` took three defects at once. Reorder it on machine
A; open it on machine B and resize the window; B's file is now newer, so the
merge takes B's order — the old one — wholesale.

_merge_playlist was a 2-way union with no common ancestor: one side's order
wholesale, the other side's extras appended at the tail, so a track inserted in
the middle on one machine arrived at the bottom on the other. merge_track_order
re-inserts each side-only track after the nearest track both copies share
instead. _reconcile_playlist (the live-reload path) uses the same helper. The
union invariant is unchanged and property-tested over 300 random pairs — a
merge never drops a track.

Whose order wins is now Playlist.date_modified, bumped only in _set_track_ids
(add / remove / reorder / undo) and deliberately not by
mark_playlist_settings_dirty, with a file-mtime fallback for playlists written
before the field existed. The file's mtime was a lie: the last column is
stretch-sized, so Qt re-fires sectionResized whenever the viewport width
changes, and a window resize or splitter drag rewrote the open playlist's JSON
— moving its mtime and handing Syncthing another conflict — for a width
apply_settings overrides on load anyway. _on_section_resized now skips that
section; genuine drags on every other column still persist.

Replayed on a copy of the real playlists: `a nissa one` keeps its reorder
against a newer-by-mtime opponent, and `a nissa ideas` takes the other
machine's two mid-list inserts at 5 and 12 rather than 26 and 27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
2026-08-20 21:29:03 -04:00

14 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, one playlists/<persistent_id>.json per playlist, and one plays/<machine-id>.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/<machine-id>.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.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) 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 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.

  • 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 <topdir>/.Trash-<uid> 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/<uid>/gvfs), not mass storage — so plain file I/O, but never copystat and never trust mtimes (diff by name+size). Sync owns exactly Music/<Playlist Name>/ 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/). 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. Registered in setup.py via package_data; loaded with importlib.resources so an installed copy finds it.

  • lintunes/cast/ — Chromecast playback (Connections menu), using the media-receiver model: server.py runs a ThreadingHTTPServer on an ephemeral port for the life of a session and the device fetches the original file itself (bit-exact, no transcode). URLs carry an opaque random token, never a path, so traversal is structurally impossible; Range + HEAD are mandatory (the device seeks by re-requesting ranges and won't report a duration without them). A token resolves to an _Asset that is either a file on disk (audio) or an in-memory blob (album art, which lives in tags rather than as its own file); the two have separate eviction rings so a cover can't push out the previous track's audio. Art is passed as play_media(thumb=…), which pychromecast folds into metadata["images"] — that's what a TV paints full-screen. support.py is the format gate — ALAC, AIFF and protected AAC are refused and Player skips them with a status-bar message. discovery.py wraps CastBrowser; sink.py is the PlaybackSink; controller.py owns the session and its own SleepInhibitor. pychromecast is imported lazily, never at module scope, so the app still launches where the dep isn't installed yet. While casting there is no local PCM, so the visualizer panel doubles as the cast indicator: it shows the cast glyph instead of bars and a click there stops casting (the brightness cycle is suppressed — it means nothing with no bars). That is the only cast control in the transport bar. Position comes from a 500 ms poll of adjusted_current_time (only trusted while PLAYING — it creeps while paused).

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).

  • Cosmetic table settings must not look like edits. The last column is stretch-sized, so Qt re-fires sectionResized for it on every viewport width change; track_table._on_section_resized ignores that section, or resizing the window would rewrite the open playlist's JSON (and hand Syncthing a conflict) purely for a width apply_settings overrides on load anyway.

  • 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). Since Round 33 the only other path is an explicit user delete (LibraryManager.delete_tracks), which moves the file to the desktop trash via trash.py — never unlink, so it stays recoverable. Nothing else may move, rewrite or remove 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).