The Device menu becomes Connections, with "Connect to Chromecast…" beside the Rabbit sync. It opens a dialog that spins while it searches and lists devices as they appear; picking one hands playback over, puts a small cast glyph under the volume slider, and clicking that glyph disconnects. Media-receiver model: lintunes serves the original file over the LAN on an ephemeral port and the Chromecast decodes it itself. Bit-exact — no transcode, no second lossy encode — and the device buffers for itself. The cost is that lintunes is a remote control while connected: no local PCM, so the visualizer shows the cast glyph instead of bars, and transport actions land with about a second of round trip. Player now walks its queue through a swappable PlaybackSink. It keeps owning the queue, shuffle walk, start/stop times and the play-count and scrobble bookkeeping, so casting counts plays and scrobbles exactly like local playback. set_sink() carries track, position and playing-state both ways, so connecting and disconnecting pick up mid-song. LocalSink stays in player.py because the Player tests stub Qt Multimedia in that namespace. The URL carries an opaque random token, never a path, so there is nothing to traverse with; only the last few played tracks stay resolvable and the whole map dies with the session. Range and HEAD are implemented because the device seeks by re-requesting ranges and won't report a duration without them. Failure handling, verified against a real device: a dropped socket gets a 15s grace period, since pychromecast retries on its own and a Wi-Fi blip heals itself. A real loss, another app taking the device, or a network change falls back to local playback still playing, at the same position — the sink reports the state lintunes last asked for rather than the IDLE status a dying connection pushes just before it goes. Quitting stops the device instead of leaving it fetching from a server that just died. The ~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar message; the other 21,000+ MP3 and AAC files cast natively. New dependency: pychromecast>=14.0.10, imported lazily so the app still launches where it isn't installed (the menu item then explains the install), and python_requires raised to >=3.11 to match its floor. NOTE: the other machine needs `pip install -e .` before casting appears. tests/test_round29.py: 67 tests; 440 pass overall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.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)walks a queue through a swappablePlaybackSink. 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(theQMediaPlayer/QAudioOutputpipeline, with theQAudioBufferOutputPCM tee that feeds the visualizer) stays inplayer.py— the Player tests stub Qt Multimedia withpatch.multiple(player_module, QMediaPlayer=..., …), so those names must resolve in this module.cast/sink.py::CastSinkis the other implementation.set_sink()carries the current track, position and playing-state across a swap and deliberately does not re-emittrack_changed(that would double-scrobble the same song). Player is also deliberately context-agnostic: 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. -
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 exactlyMusic/<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/cast/— Chromecast playback (Connections menu), using the media-receiver model:server.pyruns aThreadingHTTPServeron 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).support.pyis the format gate — ALAC, AIFF and protected AAC are refused and Player skips them with a status-bar message.discovery.pywrapsCastBrowser;sink.pyis thePlaybackSink;controller.pyowns the session and its ownSleepInhibitor. 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 shows the cast glyph instead of bars, and position comes from a 500 ms poll ofadjusted_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 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).