The Library button, the view's header (search box or playlist name) and the seek row now share one band under the transport boxes, instead of the seek row hanging alone with a header strip below it. The now-playing panel caps at 560px and the gaps between controls share the rest evenly; more room under the menu bar. Long playlist names shrink up to 2pt, then elide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 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 live on the kanban board at
~/Documents/projtracker/projects/lintunes/ (use the tasks skill); the old
TASKS.md/tasks-done.md are archived there — don't recreate them.
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 except where a removal was recorded (Round 44, seetombstones.py). 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, not by the file's mtime, which moves for cosmetic reasons. Since Round 43 a stamped copy also beats an unstamped one (a stamp exists only once LinTunes recorded an edit, so that is real evidence); mtime is the fallback only when neither side has ever been edited. Two rules follow: a merge that changes nothing writes nothing (an unconditional write reset the kept file's mtime and biased that fallback a little more every round), and a merge whose result is a union neither copy had stampsdate_modified— that content is newer than both, and saying so is what stops two machines trading the same tracks back and forth.library_manager._reconcile_playlistis the same decision on the live-reload path and must read the stamps too; it is reached only for playlists in_dirty_playlist_content(real content edits), never for one that is merely cosmetically dirty from a column drag. Since Round 42 everyConflictSummarycarries alevel(WARNING/CHANGE/INFO): a merge that only reconciled column widths, or a smart playlist whose rules are byte-identical on both sides, isINFOand must never read as an edit the user made. The dialog (gui/conflict_dialog.py) shows one level and above and opens at the highest level in the batch, so a routine merge never steals focus but a blank window is impossible either. Round 43 made the summaries say something actionable: which copy won and when it was edited, who wrote the copy Syncthing set aside (the 7-char device token in the conflict filename, named viasync_identity.py), and every re-inserted track byArtist — Titleand position — six in the window, all of them inwhat-changed.txtat the top of the backup snapshot. Never label a copy "this machine" from which file holds the plain name: that is Syncthing's choice, and it sets the local copy aside as readily as a remote one. -
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). The bar's second row (transport.HeaderRow, placed by hand) holds three things owned elsewhere: the sidebar's Library button (SidebarPanel.header, kept as wide as the sidebar viatrack_split), the shown view'sheader(search box, playlist name — mounted on the content stack'scurrentChanged, returned to its view when it goes), and the seek row, exactly under the now-playing panel. Since the Library button left the sidebar,MainWindowmust hand it the first focus, or the playlist tree takes it and opens its first playlist at startup.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(bothmusic_folder_overrideand the oldermusic_rootthat--music-root … --save-configwrites — an imported library already knows where its music is on this machine, so don't ask), 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/tombstones.py— why a deletion sticks. Two copies with no common ancestor cannot tell "A added this" from "B removed it", which is why every merge was a union — and why a song removed on one machine came back from the other forever. So removals are recorded:Playlist.track_events({track id: [when, "add"|"remove"]}) andLibrary.deleted_tracks({track id: when}, inlibrary_metadata.json). A merge takes the newest event per track across both copies and applies it, so a removal beats a copy that merely still had the song and a later re-add beats the removal; a track with no event still merges as a union. Events are recorded in the same single funnels as everything else —_set_track_idsfor playlists,_remove_tracksfor the library — and pruned afterRETENTION_DAYS(30) at the save boundary, so a machine offline longer than that can resurrect something. Three rules: track ids are never reused (_highest_track_idcounts deletions, or a new track would be dropped on sight by the dead id's own tombstone),library_metadata.jsonis merged beforelibrary.json(it carries the record the library merge is filtered against —resolve_conflictssorts for it), and a merge applying a deletion never touches a music file; it only drops the library entry, and reports at WARNING. -
lintunes/sync_identity.py— turns a conflict filename's 7-char device token into a device name, by reading Syncthing'sconfig.xmland deriving our own device ID fromcert.pem(base32 of the SHA-256 of the DER cert). Stdlib only, cached, and every failure path returnsNone— a machine with no Syncthing must still merge, just without naming anyone. -
lintunes/art_search.py— Download Album Art. Queries both the iTunes Search API and Deezer's public album search (neither needs a key) and merges them throughrank_candidates: album match beats artist match, so a right-artist, wrong-album hit never wins. Two sources because neither catalog is complete: iTunes has no copy at all of Digable Planets' Reachin', which Deezer finds on the first query (Round 60). One source failing is not an error while the other answers. Embedding, whether from there or from Get Info, goes throughgui/art_ops.embed_artwork: write,mpris.invalidate_artwork, size refresh. Get Info'sArtSquarenever stages bytes that don't decode (clipboard_imagechecks every candidate withQImage.loadFromData), because a clipboard can advertiseimage/pngand hand over nothing. Every paste attempt is logged at INFO (lintunes.gui.info_dialog), so it shows up in the journal. -
lintunes/fingerprint.py— "Identify Track…": Chromaprint'sfpcalcCLI (detected withshutil.which, theffmpeg_available()pattern — a runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API says what it is. Shaped likeart_search.py: pure parse/rank helpers first (tested offline against canned JSON), then subprocess/network, thenTrackIdentifier(QObject)on a daemon thread emitting a dict carrying eithercandidatesorerror. The year rule is the point of the feature: every candidate carries the recording's original year — the earliest release across all its release groups — even the candidate proposing a later compilation as the album, because a 60s song must not sort by the year its CD reissue came out._releasegroup_sort_keyseparately ranks a plain studio Album above EP/Single above anything with a Compilation/Live secondary type, so the default proposal is the real album. An AcoustID result's score belongs to the audio, not to any one recording: every recording linked to it carries that score, mis-tags included, and a song whose title another artist also used collects them (Round 52: trav's Dionne Farris "I Know" is linked to Jay-Z's, Marisela's, David Essex's and New Atlantic's). What tells a real link from a stray issources, the submission count — 475 against 6 and three 1s — so the lookup asks for it and_link_tiersinks anything under a tenth of the strongest link in that result. Hence the key order who, then which take, then which release: stray tier, agreement with the artist the file's own tags name (artist_hint_for), duration bucket, hint overlap, release rank. Artist before duration is the point — Jay-Z's take was 1.5 s closer to the file than Dionne's own, and that used to decide it.gui/identify_dialog.pyis passive (theAlbumArtDialogcontract — nothing written until accepted); the caller appliesresult_fields()throughLibraryManager.edit_track_fields, which is what buys tag writes, undo and artist/album file relocation for free. A row is pre-checked only where the proposal differs from the current value, so an unchecked field can never quietly blank a tag. The API key is the user's own, inpreferences.acoustid(the Last.fm precedent — preferences.json syncs, so it never belongs in git). Audio under ~3 s has no fingerprint at all ("Empty fingerprint"), which is a reported failure, not a crash. -
lintunes/filename_tags.py— the offline half of Identify Track, and the answer to its biggest limitation: AcoustID only knows music somebody submitted, so an underground/SoundCloud rip fingerprints perfectly and matches nothing (verified: "B. Clem — Zuuso" returns zero results from AcoustID, zero from a MusicBrainz text search, zero from iTunes). Its filename, though, says exactly what it is. Pure string work, no network, no Qt: strips yt-dlp's trailing id ([1025657891],-WC7gK2kgyTQ— an 11-char token is only treated as a YouTube id if it carries a digit, an underscore, or repeatedly flipping case, or "Underground" would be eaten), drops(Official Video)-style noise, splitsArtist - Titleon a spaced hyphen only (so "Jay-Z" and "350-440-DialTone" survive), collapses a doubled uploader, reads a leading1-04, restores_s→'s, and falls back to the iTunes tree (<root>/Artist/Album/NN Title.ext), ignoring placeholder dirs like "Unknown Artist".fingerprint.candidate_from_filenamewraps it as a candidate withsource="filename", appended to every lookup and standing alone when there are no matches — the dialog then says where the guess came from instead of quoting a fabricated confidence. The same filename also feedshint_for(track), which ranks real lookup results: token overlap (words worth double, bare numbers single — "alhambra" identifies a release, "1961" appears in every compilation spanning it), a coarse duration bucket off the recording lengths AcoustID returns (which is what separates a 150 s studio take from a 155 s live one), and a year named in the filename that predates anything the database knows. That last one is not a nicety: MusicBrainz's ownfirst-release-datefor "Ahmad Jamal's Alhambra" is 2002, because its three original 1961 pressings are in the database undated — so a second MusicBrainz call would return the same wrong year, and the file's own name is the only place 1961 exists. -
lintunes/url_import.py—File → Import from URL…(Round 49): trav'ssongshell helper (yt-dlp --extract-audio --audio-format mp3 "$@") run from inside the app. Those flags are passed verbatim; everything else on the command line is plumbing:-P <tempdir>,--print before_dl:LTSTART …,--print after_move:LTFILE %(filepath)sand anLTPROGprogress template, so output is read from markers rather than scraped. yt-dlp is a runtime CLI tool (shutil.which, the fpcalc pattern), never a pip dep, and needs ffmpeg for the mp3. No-o: yt-dlp's defaultTitle [id].mp3is whatfilename_tagsparses.UrlImportWorker(daemon thread,ExportWorker's shape, shares the status-bar progress widgets and so_busy_worker()) downloads into a privatemkdtemp, emitsdownloaded(path)per song, and the GUI imports each one as it lands, through the normalimport_files(a copy into the organized tree) and straight into the Identify queue, so song 1's dialog can be up while song 5 downloads. The GUI deletes the temp dir only onfinished/failed, which are queued after everydownloaded.resolve_targetis the rule for "and add to current playlist?": above the selected song in the shown playlist, else the end of the playing playlist, else the end of the shown one, else the checkbox is grayed. Smart/folder/system playlists are never targets. A batch keeps the link's order (position + songs already inserted). With no AcoustID key,TrackIdentifierproposes from the filename alone and never touches fpcalc or the network. Only a URL import reaches that path, since the menu route still insists on setup first. YouTube's "Sign in to confirm you're not a bot" is about the network (songhits it too), and yt-dlp's cure is the browser's cookies: a run that downloads nothing for that reason is retried once with--cookies-from-browser(default_cookies_browser, Firefox first), and a browser that worked is saved asytdlp_cookies_browserinconfig.json, not preferences — which browser holds a YouTube login is this machine's business (Round 59). -
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, which ignores auto-repeat: on Wayland repeats are generated client-side until the compositor delivers the release, and a busy gnome-shell turned one held arrow into 1,676 track skips (Round 56). Every list sent over D-Bus must be a typedQDBusArgument(mpris.string_array) — a plain Python list goes out asav, which GDBus rejects. That wasxesam:artist, and the error flood it caused is what kept gnome-shell busy. -
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. Its menu item ("Sync Playlist to Rabbit (Auxio)") was retired in Round 51, once andTunes could play what it syncs; the module stays becausefind_deviceand itssanitize_name/track_display/track_filename/build_m3u/CHUNKare imported by bothexport/exporter.pyandandtunes/, so their signatures are load-bearing. -
lintunes/andtunes/— the desktop half of andTunes, a music player for the Rabbit R1 (Round 47; the app itself lives inandtunes/at the repo root, R1 measurements inandtunes/DEVICE.md, tasks taggedandtuneson the board). The premise: the app never scans anything. Auxio re-reads Android's MediaStore on every launch, which is the "your songs will show up here" hang — but LinTunes already knows the artist, album and year of every file it just copied, so it writes them intoMusic/andTunes/library.jsonand the app parses one file instead of indexing a filesystem.layout.pyis the on-device shape (Media/<Artist>/ <Album>/04 Song.mp3— one copy per song however many playlists hold it — plusArt/,Playlists/,library.json);manifest.pyis pure and Qt-free;art.pyrenders one 480 px JPEG per album (not per track) via QImage — safe off the GUI thread, unlike QPixmap — cached under$XDG_CACHE_HOME/lintunes/andtunes-artand invalidated by the audio file being newer, which is exactly what embedding new art does.sync.pyis a third sibling ofplan_export/ExportWorker: pure planner, then a daemon thread with KiB progress. Three rules that bite: planning must not render art (a mutagen open per album, and planning runs on the GUI thread — the worker does it, and those bytes are absent from the progress total); the index (m3us thenlibrary.json) is written last and after a cancel is rewritten to list only tracks whose files actually landed, so the app never opens a manifest with holes in it; and every delete goes throughlayout.assert_inside, which refuses anything not strictly inside the andTunes root. That guard is why the oldMusic/<Playlist>/folders are structurally unreachable rather than merely un-referenced, andButtons/(the user's own menu artwork) andplays/(written by the app) are skipped by the device scan entirely — sync reads those, it doesn't own them. Which playlists go on the device lives inpreferences.jsonunderdevice_sync(so it rides the Syncthing share and both machines agree); keeping it off thePlaylistkeeps it out of the conflict-merge machinery, and unticking is the only way a playlist comes off the device. The device's filesystem is case-insensitive, so "RJD2" and "Rjd2" are one folder there and one artist here: since Round 54 the app'sLibrary.group()keys bothalbumsByKeyandartistsByNamecase-folded (and fetches the artist before the album block, holding it — the raw-case lookup afterwards is what NPE'd on the second track of a two-spelling album and blanked the whole library), andplan_andtunes_syncfolds both the collision rule and the device diff, which had been re-copying every such file over MTP every sync.plan.stalestill carries the device's own spelling — that is what_delete_staleunlinks by. A gvfs-MTP mount can hold a phantom directory — one it lists happily while the device has no such folder — and every write into it failsEIOforever, becausemkdir(exist_ok=True)sees the phantom and does nothing. Only remounting clears it (gio mount -u mtp://…thengio mount). Two rules came out of it (Round 55): a copy that raisesOSErrorcosts that song, not the sync (it lands in theunwritablesummary list), and the song is then left out of the m3u andlibrary.json— a manifest naming a file that isn't there is worse than a short one, because the app skips to the next track and the user sees the wrong song play. Planning is a worker too (AndTunesPlanWorker):plan_andtunes_syncis pure, but it walks every file on the device, and over MTP that froze the window long enough for GNOME to offer to kill LinTunes mid-sync. Anything after the last album (_write_index, buttons, pruning) must keep emitting progress — it was minutes of silence with the line stuck onAlbum art 501/501, and_write_indexreadsArt/in one listing, never a stat per album. Since Round 50 the app exists:andtunes/app/is plain Java against the Android framework, built byandtunes/build.py(aapt2 → javac → R8 → zipalign → apksigner) — no Gradle, no Kotlin, because platform 33 + build-tools 34 were already on disk and trav didn't want a 1 GB download over cell for tools that make the same APK.--shipcopies it tolintunes/android/andTunes.apkwith its version inandTunes.json; re-run--shipwhenever the app changes, since the committed APK is what the self-updater carries and whatinstall.pyinstalls.andtunes/andtunes.keystoreis committed on purpose (both machines must sign identically oradb install -rrefuses the update).install.pyis Connections → Install andTunes on Rabbit…: adb as a runtime tool, then the all-files + notification grants (a refused grant is not a failed install — the app's first-run screen covers it), or an MTP copy toDownload/with no adb. Sync copiesbuttons/*.png(rendered byandtunes/tools/make_buttons.py) into the device'sButtons/only where the file is missing — those names are a contract with the app'sUi.button(), which prefers the device copy.proguard.prokeeps no debug attributes because R8 8.2 NPEs on javac 21's. Round 51 closed the loop. The planner runs every track throughexport/web_support.conversion_for(Android decodes the browser set), so FairPlay is refused and ALAC/AIFF land as.flac— converted once into$XDG_CACHE_HOME/lintunes/andtunes-flac/<track id>.flacand size-diffed from there on, so a converted song isn't re-copied every sync. And the app keepsplays/andtunes-<install id>.jsonin the play-journal shape, counted on a natural finish likePlayer;plays.bring_backfolds it into<data_dir>/plays/withplay_journal.merge_totals(per-track max — that file has two homes and both desktops may bring it back) before anything is copied, writing nothing when nothing moved.PlayJournal.loadthen treats the R1 as one more machine. A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits:timeoutcan't kill a process stuck on it, andfind_device()— called on the GUI thread every time the Connections menu opens, and by the GUI tests — freezes with it. USB re-enumeration (lock/unlock withmtp,adb, anadb install) is what wedged it in Round 51. Recovery:killthegvfsd-mtpprocess, thengio mount mtp://<device>/. -
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/; no m3u — it's a folder you upload, not one you open in a player, soplan.m3u_nameis""there). 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/cassette/— friend library sharing (Round 63+; the spec isCassette friend library.md). LinTunes never talks to a friend: it drives the local Syncthing's REST API (syncthing_api.py, key/address from Syncthing'sconfig.xml, overridable inconfig.json) and Syncthing moves the bytes. Every failure is aSyncthingError(step, detail, kind), because the UI promises never to fail silently. One of trav's machines is the host (host.py:config.jsonflag + a syncedpreferences.jsonrecord naming it); everything lives in the machine-local~/.local/share/lintunes/cassette/(state.json+friends/<token>/ {out,in,cache}), never the synced data dir — Syncthing folders must not nest. A friendship is two folders,lintunes-cassette-<token>-a(inviter → invitee) and-b, each send-only for its writer and receive-only for the reader. Syncthing hides an unknown device's folders (verified on 1.30: a stranger is only a pending device), so while an invite is open the inviter'sCassetteServiceprobes a new knock — adds it with nothing shared, reads which folder it offers, completes on the right-btoken or removes and remembers it (state.rejected). With no open invite nothing is probed and knocks stay pending, untouched. The paste side can't tell "their computer is off" from "their LinTunes is closed" (Syncthing briefly reports a stranger's connection as up), so it shows one combined status.scripts/cassette_pair.pystarts throwaway Syncthings on loopback (no discovery there, henceCassetteService(addresses_for=…)); tests markedsyncthingrun a real handshake withpytest --syncthing. The service starts fromrun_gui, never from constructingMainWindow— tests build windows by the dozen and must not reach a real Syncthing. Friend mode (gui/friend_mode.py, entered from the ▾ beside Library) shows a friend'slibrary.jsonthrough a secondLibraryViewovercassette/friend_library.FriendSource— the slice ofLibraryManagerthat view reads, read-only. Their track ids are theirs: the table isset_read_only(True)(no play, no drag, no copy into my playlists, only "lookup on youtube"), and the cassette column istrack_table.CASSETTE_FIELD(registered inCOLUMN_MAP, deliberately notALL_COLUMNS), live only wherecassette_hooksis set. Nothing is written until Save and Close (service.save_requests→requests.json); any other exit with changes asks "Save changes?".cassette/matching.pyis best effort (exact vs fuzzy);cassette/space.pyjudges the music disk (red) and Syncthing'sminDiskFreefloor on the cassette disk (orange) separately. Delivery (cassette/delivery.py+gui/cassette_delivery.py) is rebuilt from the folders on every pass, both directions: sender = theirrequests.json∩ what I offer now →outbox/<id> ~ <name>(copied to a.partthe.stignorehides, then renamed), and anything no longer requested is deleted — a request vanishing is the delivery receipt. Receiver: planning on the GUI thread (it reads the library), copying onDeliveryWorker's thread (file_importer.stage_file— the import split so the copy is off the GUI thread),manager.add_trackback on the GUI thread (track_from_file: date added now, no listening history). An exact match already in my library is never imported twice, which is what makes a crash between import and requests-rewrite harmless; a missinglibrary.jsonprunes nothing. Followed playlists (a UFO in friend mode) are neverPlaylists of mine:PlaylistTree.followed_providerinjects them (mixed alphabetically or in a friend folder, perFriend.layout), andgui/followed_view.pyplays them fromfriends/<token>/cache/. Player queue entries may be string keys (cassette:<token>:<their id>) resolved byPlayer.track_resolver; a key scrobbles (track_finished) but never callsrecord_play. Requests are always computed byservice._write_requests(cassetted ∪ followed songs not yet cached), so a save never re-asks for what's already in the cache, and a pass prunes cache files no followed playlist uses. -
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
-
The Rabbit R1's screen is not what the internet says. Measured: 480 × 640 px, physical density 320, override density 160 — so an app sees a 480 × 640 dp canvas on a 2.88" panel (~278 real ppi). One dp is about half its usual physical size, so andTunes doubles every stock value (rows ≥ 88 dp, text 28–32 sp). Don't "fix" it by changing the device density; trav has it where he wants it. Android 13 / API 33, arm64-v8a. The scroll wheel emits KEY_UP/KEY_DOWN (
KEYCODE_DPAD_UP/DOWN). -
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. Round 42 is the same rule one level down: a live smart playlist's membership is never persisted (Playlist.has_derived_membershipgates it injson_storage.save_playlist, which writestrack_ids: []+derived_membership: true) andrecompute_smart_playlistpassestouch=Falseto_set_track_idsso it marks nothing dirty and moves no timestamp. Membership is rebuilt from the criteria on every load (recompute_all_smart), so storing it only bought a conflict per song — one per finished track, on the same file, from both machines. The exceptions arelive_update=Falseandunsupportedcriteria: theirtrack_idsare the content (a snapshot), so they still persist and still count as edits. -
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. -
Never develop or verify against trav's real library. It is 21,531 tracks of irreplaceable music and no amount of read-only care makes it the right thing to point a half-finished feature at.
scripts/make_dev_library.pybuilds a synthetic one that has the same shapes — every playlist type (regular, folder, live/non-live/unsupported smart, system, empty, duplicates), six container formats, art on some albums and not others, a play journal, a tombstone, and a deliberate pile of naming edge cases (a collision pair differing only by id, an emoji album, a 208-character title, forbidden characters, a dangling location). Build it withpython3 scripts/make_dev_library.py --out dev-libraryand run against it with--data-dir dev-library/data; it's gitignored, the generator isn't. When a feature needs a shape the fixture lacks, add it to the generator rather than reaching for the real library. -
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).