The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.
The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.
The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.
Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
17 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, oneplaylists/<persistent_id>.jsonper playlist, and oneplays/<machine-id>.jsonper machine. 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. 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 byPlaylist.date_modified— bumped only inLibraryManager._set_track_ids— not by the file's mtime, which moves for cosmetic reasons.library_manager._reconcile_playlistuses the same helper for the live-reload path. -
lintunes/storage/play_journal.py— why play counts can't conflict. Since Round 38library.jsonholds only a base count and each machine ownsplays/<machine-id>.jsonwith 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, thediskcopy insidereload_from_disk) — folding an already-folded library promotes the effective count to base. Mirror-image rule injson_storage.save_tracks(): it writesjournal.base_fields(tid), never theTrack's effective count. Those two places are the whole hazard;tests/test_round38.pypins both. -
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 and smart playlists (criteria parsed bysmart.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<data_dir>/preferences.json(rides the same Syncthing share).Preferences.set(key, value)saves and emitschanged;MainWindow._on_prefs_changedre-applies theme/metrics live. Because it is synced, anything machine-specific belongs inconfig.py'sconfig.jsoninstead (seemusic_folder.py).now_playing_fonthas three states —Noneautomatic (best available fromtheme.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_folderabsolute (what pre-0.10 code reads — kept forever as the shared floor between versions),music_folder_relrelative to the data dir (the portable one, same trick asTrack.location), andmusic_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 inconfig.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_foldermust callmark_library_settings_dirty()(without itreload_from_diskreverts the change on the next sync tick), andorganize_root()can returnNone, which every caller must handle rather than inventing a relative path —MainWindow._music_import_dirused to fall back toPath("Music"), which resolved against a working directory GNOME's dash doesn't set predictably. -
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/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-encodedPath=, not in~/.local/share/Trash(which would be a cross-device copy the file manager can't "Restore"). The.trashinfois created withO_EXCLfirst 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. RaisesTrashErrorwithout touching the file, so a caller can treat failure as "not deleted". OnlyLibraryManagercalls 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 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/export/—File → Export Playlist…(also on a playlist's right-click menu). A sibling ofdevice_sync, reusing its filename helpers andbuild_m3u: pureplan_export()first, thenExportWorkeron a daemon thread. Two destinations — a folder (files asArtist - Title.extplus an.m3u) or a web mix (index.html+audios/+ hero image, fromtemplates/). The manifest is written last, so an interrupted export never leaves a page or m3u naming files that aren't there.web_support.pyis the format gate, shaped likecast/support.py: deny-by-default on the suffix with the iTuneskindbreaking the.m4atie. 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-freeplayer.js/player.cssthat replaced audio.js + jQuery (abandoned since 2012; jQuery was only ever glue — audio.js never used it).player-graphics.gifmust 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 withfilter: 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 inindex.html, soplayer.csscan read it while staying a verbatimshutil.copyfile— the page is still the only rendered template.exporter.normalize_accentis a hard gate, not politeness: the value lands raw inside a<style>block andstring.Templateescapes nothing. The accent is not persisted, so an untouched export still renders the original#8c764a. Templates are registered insetup.pyviapackage_data; loaded withimportlib.resourcesso an installed copy finds them. -
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). A token resolves to an_Assetthat 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 asplay_media(thumb=…), which pychromecast folds intometadata["images"]— that's what a TV paints full-screen.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 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 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). -
Cosmetic table settings must not look like edits. The last column is stretch-sized, so Qt re-fires
sectionResizedfor it on every viewport width change;track_table._on_section_resizedignores that section, or resizing the window would rewrite the open playlist's JSON (and hand Syncthing a conflict) purely for a widthapply_settingsoverrides on load anyway. -
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). Since Round 33 the only other path is an explicit user delete (LibraryManager.delete_tracks), which moves the file to the desktop trash viatrash.py— neverunlink, 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__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).