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
39 KiB
Done
Round 41 (2026-08-22) — Web mixes pick their own color (v0.11.0)
Every exported mix came out in the same 2010 gold-and-brown skin: a #c7b563
player bar, #ccc progress fill, and a hard-coded #8c764a behind every hover.
Now the hover backgrounds and the progress fill are one color the user picks in
the export dialog, and the rest of the bar is deliberately colorless so that
choice is the only color in it.
- One accent, chosen per export. A swatch button in
WebMixDialogopeningQColorDialog, plus Reset. Deliberately not persisted — it opens onexporter.DEFAULT_ACCENT(#8c764a) every time, so an export nobody touches still looks exactly like the mixes already online.exporter.normalize_accentis the injection gate, not a nicety: the value is substituted raw into the page's<style>block andstring.Templateescapes nothing, so anything that isn't#rgb/#rrggbbfalls back to the default. - Delivered as a CSS custom property,
:root { --accent }declared inindex.html, whichplayer.cssreads asvar(--accent, #8c764a). That is what lets the stylesheet stay in the verbatimshutil.copyfileloop —index.htmlis still the only thing rendered. - Hover text flips black or white against the accent
(
exporter.contrast_text, the same luminance ruletheme.apply_themeuses). The old page hard-coded white, which vanished the moment anyone picked a pale color. - The player bar is fixed light gray with black text:
#eeebehind the transport,#000for both elapsed and duration, a gray buffered segment, and a dark box-shadow in place of the near-white one that was invisible on the white page it always sat on. - Black glyphs without touching the GIF. The sprite's play/pause are
pale lavender and the spinner is yellow — drawn for the dark gold bar and
invisible on light gray.
filter: brightness(0)zeroes every channel and leaves alpha alone, so they paint solid black and the loading frame still animates.player-graphics.gifstill ships byte-identical. theme.swatch_icon()—preferences_dialog's private_swatchpromoted so the export dialog isn't a second copy of it.
Verified in Chrome against two generated mixes: a dark teal (#0f8a7e) accent
gives white hover text, a pale yellow (#f2e96b) gives black, and both paint
the progress fill while the bar around it stays gray with a visible black
play/pause.
Round 39 (2026-08-20) — Playlist merges stop losing your track order (v0.9.1)
The playlist half of the Round 36 rework, and what actually scrambled
a nissa one. It took three defects together: reorder it on machine A, open it
on machine B and resize the window, and B's file is newer, so the merge takes
B's order — the old one — wholesale.
- Anchor-based merge.
merge_track_orderinconflict_resolverunions two orderings by re-inserting each side-only track after the nearest track both copies share, instead of appending it at the tail._merge_playlistandlibrary_manager._reconcile_playlist(the live-reload path) both use it. The union invariant is unchanged and property-tested over 300 random pairs: a merge never drops a track, duplicates and disjoint lists included. Playlist.date_modified, bumped only in_set_track_ids— the single funnel for add / remove / reorder / undo, and deliberately notmark_playlist_settings_dirty._playlist_newerprefers it over the file's mtime, falling back to mtime for playlists written before the field existed. The smart-playlist branch uses the same test, which told the same lie.- A window resize no longer rewrites the playlist. The last column is
stretch-sized, so Qt re-fires
sectionResizedfor it whenever the viewport width changes — a resize or a splitter drag rewrote the open playlist's JSON, moved its mtime, and handed Syncthing another conflict, all for a widthapply_settingsoverrides on load._on_section_resizedskips that section; genuine drags on every other column still persist.
Replayed against the real playlists on a copy: on a nissa one (23 tracks) the
reorder survives a newer-by-mtime opponent, and on a nissa ideas (26 tracks)
the other machine's two mid-list inserts land at 5 and 12 rather than 26 and 27.
Round 38 (2026-08-20) — One merge window, and play counts that can't conflict (v0.9.0)
Fifteen "Synced changes merged" windows were stacked on the desktop. Two
independent bugs, and the .resolved/ backups said which mattered: every one of
the last nine library.json merges was ~98% play counts (43-47 of ~45 changed
tracks) plus exactly one real edit — the same single rating each time. The same
~45 tracks disagreed in every merge and the count only crept down (48 → 44 over
a day), so max() had been quietly discarding plays for at least that long.
- One window, not one per merge.
show_conflict_summarybuilt a freshConflictSummaryDialogeach time and only reassigned the attribute — the old dialog stayed a live child of the window. Sincecheck_for_external_changesrunsresolve_conflictson every Syncthing watch tick, that was one window per merge for the life of the process. The dialog is now a session log:add_event()folds each merge in as its own timestamped entry (newest first), the restore button names the merge it would undo, andfinishedclears MainWindow's reference so a close lets the next merge open a fresh one. - Per-machine play journals.
library.jsonnow holds only a base count; each machine ownsplays/<machine-id>.jsonwith its own per-track totals, and the effective count is base + the sum of all journals. Only the owner writes its journal, so play data can't conflict; totals rather than an append log, so there's no compaction step to double-count in; and a machine still on older code keeps bumping its own base, which stays additive with our journal, so the version-skew window is safe too. On the real 21k library: three plays wrote 83 bytes and leftlibrary.jsonuntouched, where before each one rewrote 15 MB. - The two hazards, pinned by tests.
save_tracksmust write the base andPlayJournal.loadmust be handed a base-valued library — the second is whyreload_from_diskfolds the journals onto thediskcopy before reconciling, or the max() against the effective in-memory count would win by exactly this machine's journal. - Journal conflicts merge by highest total rather than newest-wins. They
shouldn't be possible; if a filesystem oddity makes one, falling through to
_keep_newerwould discard a machine's whole history.
Deliberately not done: lossless merges still open the window rather than demoting to a status-bar line. With journals landing, a merge stops being routine — the ones left are real edits, and worth seeing.
Round 37 (2026-08-19) — Export a playlist: a folder, or a whole website (v0.8.0)
File → Export Playlist…, and the same item on a playlist's right-click menu.
Two destinations behind one planner, built as a sibling of device_sync (same
shape: a pure plan_export, then a worker on a daemon thread reporting through
signals, sharing the status-bar progress widgets and the cancel button).
- Folder export — the audio files under
Artist - Title.extnames next to an extended.m3u. No.pls; nothing reads it any more. Files are copied byte-for-byte, never re-encoded. - Web mix export — a self-contained static site reproducing the
hand-made yearly mixes:
index.html,audios/, a hero image, and a dialog for title / description / image. The description is deliberately passed through raw (the mixes lean on inline links right there); the title and track labels are escaped. Per-track liner notes stay hand-edited —index.htmlcarries a commented-out block in the right spot. - audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — not exploitable on a
static page, since nothing untrusted ever reaches a jQuery HTML sink, but
dead weight either way). The decisive detail: audio.js never used
jQuery — jQuery was there for ~25 lines of tracklist glue. Replaced by a
~180-line dependency-free
player.js+ aplayer.csstranscribed from the customized audio.js skin, so the page looks the same: same 250px#c7b563bar, sameplayer-graphics.gif(shipped byte-identical — it's an animated GIF, the loading state is a spinner), same click-to-play, autoplay-next and ←/→/space shortcuts. ~293 KB → ~6 KB. The Flash fallback went with it; audio.js gated it on!canPlayType("audio/mpeg;"), unreachable in any browser since ~2010. - Conversion only when a browser genuinely can't play it, and never lossy.
export/web_support.pyis a deny-by-default gate modelled oncast/support.py, using the iTuneskindstring to disambiguate.m4a(plain AAC / Apple Lossless / FairPlay all wear that suffix). Bitrate is not a trigger — a 320 kbps MP3 is copied verbatim. Everything that does convert targets FLAC, so a conversion cannot cost a bit; a test asserts the exported FLAC's decoded PCM hashes identical to the ALAC source. Against the real library that is 105 Apple Lossless + 8 AIFF out of 21,382 tracks. DRM'd tracks (6 Protected AAC) are reported in a confirmation dialog, never silently dropped and never attempted. - ffmpeg is detected, not assumed — it's the CLI binary, which is a different thing from Qt Multimedia's ffmpeg backend. Missing, the export offers to go ahead without the affected tracks.
- The manifest is written last. A cancelled or interrupted export leaves
no
index.htmland no.m3u, so a half-finished folder never advertises files that aren't there. Cancel also removes the in-flight partial file. - Verified in Chromium 151 and Firefox 153 driven over CDP/Marionette:
all four exported formats decode (including the converted FLAC), the
player builds, the gold bar and sprite render, click / space / ←→ /
scrubber-seek / autoplay-next all work, and the network log shows no
request for jquery, audio.min.js or any
.swf. mix-example/removed — it was the reference for the template and the template now reproduces it.
Round 35 (2026-08-19) — Startup speed: the 21k-track library stops freezing (v0.7.0)
Reported as three separate complaints — "startup takes a minute or two and GNOME
offers to force quit", a playlist insert that jumped to the end, and the pile of
merge dialogs — which turned out to share one shape: every operation was
whole-library, whole-file, on the Qt main thread. Measured against the real
library (21,482 tracks, 506 playlists, 15 MB library.json).
- The track table sorts itself.
TrackTableViewran its model through aQSortFilterProxyModel, which asksdata()for a value on every comparison — 580k Python round trips, 8.5 s per table load, paid again on every reload. The model now keeps_tracksin canonical (playlist) order plus an_orderindex list, and sorts a key list computed once per track. Nothing used the proxy's filtering (the column browser filters by handingset_tracksa shorter list). Sorting by#is the identity order, so "source row" still means "playlist position" for drag-reorder. 8.5 s → 0.13 s.MainWindow()18.1 s → 0.79 s. - Smart rules compile to closures once per
evaluate()instead of being re-dispatched per track: the operator lookup, casefolding the query, and parsing the rule's own date constants all leave the 21k-iteration loop (_match_datewas re-parsing its own constant 21,482 times per rule). Verified against the previous implementation on all 20 real smart playlists: identical membership, 0 mismatches. 2.54 s → 0.72 s. - Smart recompute deferred until after the first paint (
main.py), so the compositor always has a window that answers. _reconcile_trackskips the twoto_dict()round trips when a track is unchanged (MERGEABLE_FIELDSin the resolver is now the single source of truth for what a merge can touch). A sync changes a handful of tracks out of 21k; this was most of the cost of a reload.- No git subprocess on the startup path:
Updater.enabledis probed lazily on the background check thread (it was tworev-parsecalls with 15 s timeouts in__init__), and the version button loads its commit-hash tooltip after the window is up. .resolved/is pruned to the newest 10 snapshots. It had reached 1.7 GB across 73 snapshots, never pruned — and it lives inside the Syncthing share, so every 15 MB pre-merge copy was being replicated to the other machine.
Net: time-to-interactive-window ~15 s → 3.2 s, and the mid-session freeze when Syncthing delivers a change (which is what GNOME was actually offering to force-quit — a full reload + recompute + table rebuild with the window already on screen) ~16 s → 3.8 s.
Not in this round, deliberately: the merge rework itself (playlist ordering, per-machine play journal, quieting the dialog) — see TASKS.md.
Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1)
The long-running "LinTunes plays by itself" haunting, finally attributed and fixed. Five-plus incidents since July, always the same shape: a few seconds of audio from the middle of a song, at some random hour, hours after the app was last touched, never recording a play count.
It was never a control event. The v0.1.4 provenance log had zero entries
between the 17:48 pause and the 00:30 incident — no MPRIS, no media key, no
AVRCP ghost — while MPRIS still reported Paused at exactly 6974000µs, the
same position it was paused at seven hours earlier. Meanwhile PipeWire showed
LinTunes owning a stream in state running, linked to the DAC, gain 1.0.
Cause: a paused (or stopped) QMediaPlayer on the FFmpeg backend keeps its
PipeWire stream open and never corks or drains it, so the few seconds the
backend had decoded ahead sit there live. When the audio graph is later rewired
— a USB DAC waking from idle suspend, a device appearing — that stale buffer
flushes to the speakers. It explains every observation: always a short burst
(it can only be what was buffered), always mid-song, never a play count (nothing
resumed), and why the Fedora machine never does it (no USB DAC to wake up).
Reproduced standalone on Qt 6.8.2 / FFmpeg 7.1.5 in ~20 lines.
PARK_AFTER_IDLE_S(30s) +LocalSink._park()/_unpark(). After that long idle, release the pipeline:stop()thensetSource(QUrl()), which is verified to remove the PipeWire node outright — there is no buffer left that could ever leak. Resume rebuilds the source and rides the existing custom-start-time machinery to seek back.- Two independent layers, deliberately: gain goes to 0 before the
teardown, so anything still buffered downstream drains silently even if
the release misbehaves.
_apply_volume()forces silence while parked, so the volume slider can't re-arm it. stop()arms the brake too — Qt'sstop()leaves the source loaded, andPlayer._advance()calls it when a playlist runs out, so "playlist ended at 1am" was a second, equally real leak path.- Parked state serves
position_ms()/duration_ms()from a cache and suppresses the media object's position/duration/state/status signals, so the teardown never reaches the UI as a jump to 0:00 and a strayEndOfMediacan't advance the queue. seek()while parked moves the resume point instead of poking a dead pipeline;load()discards parked state;shutdown()disarms the timer.- Verified end-to-end against the real
LocalSink(silent WAV, zero gain, watched viapw-dump): stream present while playing, still present right after pause, gone once parked, rebuilt on resume with position and duration intact. Tests intests/test_round34.py(17).
Note: this is a workaround for an upstream Qt Multimedia bug, not a LinTunes design flaw — GStreamer-based players get correct pause behavior for free by corking. See the backend note under "Parked / deferred".
Round 33 (2026-08-14) — Delete songs from the library (v0.6.0)
An iTunes 12 habit LinTunes had no answer for: getting rid of a song you don't
want to keep. LibraryManager had add_track but no inverse, and nothing in the
app had ever deleted a local music file. Deletion goes to the desktop trash
rather than unlink, so it's recoverable both by Ctrl+Z and from the file
manager afterwards.
Two constraints shaped it. The trash is per-filesystem — the music sits on a
mounted volume, so the right destination is <topdir>/.Trash-<uid>, and using
the home trash would mean a cross-device copy plus a recorded original path
"Restore" can't reach. And playlist cleanup has to be synchronous with the
removal: playlist edits address tracks by row index into track_ids while the
view skips ids missing from the library, so a dangling id would desync the two
and make a later "Remove from Playlist" hit the wrong track.
lintunes/trash.py— freedesktop Trash spec 1.0 by hand, no new dependency (a new pip dep would need a manualpip install -e .on the other machine). Picks the trash by the file's own mount, writes the percent-encoded.trashinfowithO_EXCLfirst to claim the name, then renames the file in; a failed rename cleans up the info file.TrashErrorleaves the file untouched.LibraryManager.delete_tracks(ids, delete_files=)— the single funnel. Removes fromlibrary.tracksand every non-smart playlist, prunes the dirs it emptied, invalidates the exported artwork, and pushes one undo Command covering the whole selection. A file that can't be trashed keeps its track rather than leaving an orphaned entry. Newtracks_removed/tracks_restoredsignals.- Undo restores the file from the trash and the track's original position in each playlist; redo re-trashes (re-recording paths, since a redo can land on a different collision-suffixed name). If the trash was emptied behind us, the library entry is restored anyway — a visible broken track beats a silent second loss.
Player.drop_tracks()— stops if the deleted track is playing, otherwise keeps the current track's place in the shortened queue. Previously a dead id in the queue made_load_currentstop everything with an error dialog once the walk reached it.- Context menu gains "Remove from Library" and "Remove from Library and
Delete File", in both the library and playlist views. No keyboard
shortcut —
Delstill means "remove from playlist". Confirmation uses the existing destructive-dialog idiom (DestructiveRole button, Cancel as default) and names the playlists affected. tests/test_round33.py(26 tests) covers the spec details (volume vs home trash, percent-encoding, collision suffixes, no orphaned info file on failure), the manager (playlist cleanup, pruning, partial failure, undo of position, emptied-trash undo) anddrop_tracks. Verified for real against/run/media/trav/tummult/.Trash-1000with a scratch file.
Round 32 (2026-08-14) — Custom start times survive a track change (v0.5.2)
trav reported "Pretty Girls is a Motherfucker" starting at 0:00 despite its
imported 0:40 start time. Import and storage were fine (70 tracks carry a start
time, 72 a stop time); the seek was being thrown away. QMediaPlayer.setSource()
synchronously emits two status changes before it returns — a LoadedMedia
still reporting the outgoing source, then LoadingMedia for the new one —
and the stale first event consumed the one-shot _pending_start_ms armed by
Round 22, leaving nothing for the new track's real LoadedMedia ~5 ms later.
So it misfired on every track change; only the first track after launch worked,
which is why Round 22 looked green.
- The armed seek is tagged with the URL it belongs to
(
_pending_start_url), and_on_media_statusconsumes it only whensource()matches — a status change fired for other media can't eat it. LocalSink.loadrestructured so the pending seek is armed only on the new-source branch; Round 22's same-source replay path still seeks directly and arms nothing.tests/test_round32.pymodels the real Qt event sequence (asetSourceside effect that fires the staleLoadedMediabefore switchingsource()), which Round 22's plainMagicMocknever did. All three behavioral tests fail on the old code.test_round8/test_round22stubs updated to report the new source by the time itsLoadedMediaarrives, as real Qt does.- Verified against the real files headless: switching from a playing track
to the Psychic Vagina mp3 with
start_ms=40000now lands at 40.1 s and climbs; a track with no start time still starts at 0.
Round 31 (2026-08-13) — One cast control, not two (v0.5.1)
The cast glyph appeared both under the volume slider and inside the visualizer,
which was redundant. Dropped the volume-slider indicator (gui/cast_indicator.py
deleted); the visualizer panel is now the single cast control.
- The visualizer panel is the indicator — bigger glyph (20 → 40 px),
always painted in the theme highlight rather than following the brightness
mode, and clicking it stops casting instead of cycling on/dim/off (which
meant nothing with no bars to dim). New
cast_stop_requestedsignal. transport_icon(kind, color, size=…)scales the painter instead of upscaling a 20px pixmap, so the panel-sized glyph is crisp;sizejoins the cache key.- Collapsed the
cast/cast_connectedglyph pair into one — only the connected state was ever drawn.
Round 30 (2026-08-13) — Album art on the cast device (v0.5.0)
The Chromecast is on a TV, so it should show the cover. play_media now
carries thumb=, which pychromecast folds into metadata["images"] — the
field the receiver paints full-screen. Verified against the real device: it
fetches both the audio and the artwork URL from us on every track change.
TrackServerserves in-memory blobs — album art lives in the audio file's tags, not as a file of its own, so a token now resolves to an_Assetthat is either a path or bytes. Audio and art get separate eviction rings, so a cover can't push out the previous track's audio while a device is still fetching it. Range and HEAD work on both.support.image_type_forsniffs the cover's type from its magic bytes rather than trusting the tag — ID3 APIC mimes are routinely wrong or blank, and the receiver silently drops an image whose type doesn't match.- Best-effort throughout — no cover, junk where the cover should be, or an unreadable file all just play without art rather than failing the load.
- Also sends
albumArtistandtrackNumberin the metadata.
Round 29 (2026-08-13) — Cast to Chromecast (v0.4.0)
The Device menu is now Connections, with "Connect to Chromecast…" alongside
the Rabbit sync. Picking a device from the search dialog hands playback to it;
a small cast glyph appears under the volume slider and clicking it disconnects.
See tests/test_round29.py (67 tests).
Uses the media-receiver model: lintunes serves the original file over the LAN and the Chromecast decodes it. Bit-exact (no transcode, no double-lossy on already-lossy files) and the device buffers for itself; the cost is that lintunes becomes a remote control while connected — no local PCM, so the visualizer shows the cast glyph instead of bars, and transport actions land with ~1 s of round trip.
lintunes/cast/—support.py(format gate, source-address selection, range parsing),server.py(token-addressedThreadingHTTPServerwith Range/HEAD),discovery.py(CastBrowserbehind Qt signals),sink.py(CastSink),controller.py(session lifecycle + its ownSleepInhibitor).Playersink split — newPlaybackSinkbase withLocalSinkandCastSink;set_sink()carries track/position/playing-state both ways, so connecting and disconnecting pick up mid-song. Play counts and Last.fm scrobbles fire identically on both paths.- Format handling — 12,610 MP3 + ~8,750 AAC cast natively; the ~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar message rather than stalling the device.
- Failure handling — a dropped socket gets a 15 s grace period (pychromecast retries on its own, so a Wi-Fi blip heals itself); a real loss, a foreign app taking the device, or a network change falls back to local playback still playing, at the same position. Quitting stops the device rather than leaving it fetching from a dead server.
- New dependency:
pychromecast>=14.0.10, imported lazily so the app still launches where it isn't installed;python_requiresraised to >=3.11 (its floor). The menu item explains thepip install -e .when missing.
Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite)
- Confirm Last.fm scrobbling works
- Volume slider between the visualizer and the timeline
- Preferences: per-area color adjustments (background, button round-rects, now-playing text + its backing, tracklist stripe gray), each with a live black↔white slider
- Visualizer: discrete light-gray-on-light-gray mode
- Cut/copy/paste tracks (Ctrl+X/C/V); paste inserts above the selected track, not at the end
- Auto-scroll the track list when dragging a track above/below the visible area
- Move data dir to
/run/media/trav/tummult/music/lintunes/+ relative track paths (see Round 15) - Real library import: 21,382 tracks, 462 playlists + 21 folders, 18 smart playlists (3 kept as snapshot), 5 missing files, 1,324 case/unicode path fixes (see Round 15)
Round 16 (2026-07-02) — Live multi-machine sync + conflict alerts
Both machines now reflect each other's changes within a couple seconds, with
genuine conflicts auto-merged (backed up first) and surfaced in an alert. See
tests/test_round16.py (9 tests) + the known limitations below.
- Live external-change reload — new
lintunes/sync_watcher.py(QFileSystemWatcher, 600 ms debounce, re-arms after atomic-rename replaces).LibraryManager.reload_from_disk()re-reads and reconciles disk into memory (max play/skip counts, newest edit wins, playlist membership unioned), keeping local unsaved edits and object identity so open views stay valid; then refreshes the UI without touching the player. Self-write suppression:flush()records each file's(mtime, size)in_own_sigs; the watcher ignores changes matching our own writes so a save never triggers a reload. - Auto-merge + backups + summaries —
storage/conflict_resolver.pynow backs up both sides into<data>/.resolved/<timestamp>/{original,incoming}/before merging, returnslist[ConflictSummary], and addsrestore_backup(). Conflicts are resolved at startup and live (via the watcher). - Alert UI —
gui/conflict_dialog.pyConflictSummaryDialog(scrollable summary, Open backup folder, Restore pre-merge backup), shown modeless from a status-bar notice so a merge never interrupts playback. Wired inmain_window(_on_library_reloadedpreserves scroll+selection;_on_conflict_resolved).
Known limitations (v2): a simultaneous conflicting playlist edit unions membership, so a deletion made on one side while the other edits the same file can be resurrected (clean, non-simultaneous deletes reload fine) — proper fix is per-playlist tombstones. No per-change accept/refuse yet (whole-file restore only).
Round 15 (2026-07-01) — Go live: portable multi-machine paths + final import
Prepared LinTunes to become the canonical library, synced to a second machine via
Syncthing. See tests/test_round15.py (10 tests).
- Portable track paths (
lintunes/paths.py) — track locations are stored relative to the data dir (to_relative) and resolved back to absolute on load (to_absolute), applied only at thejson_storageboundary (save_tracks/load_library).Track.locationstays absolute in memory, so the player, tagging, and art code are untouched. Because the data dir and the music live inside one synced tree and move together, the library now resolves on any machine no matter where Syncthing mounts the folder — no per-machinemusic_rootconfig. Absolute paths in olderlibrary.jsonfiles still load (back-compat). - Final import + go live — fresh import of the current
iTunes Library.xmlinto the synced data dir/run/media/trav/tummult/music/lintunes/(inside the verified "music" Syncthing share), config saved via--save-config, preferences (UI tuning + Last.fm) carried over from the old./data, which is retained as a rollback backup. Git remote set togit.autonomic.zone:2222/trav/lintunesso the second machine can clone.
Round 14 (2026-06-26) — Smart playlists (Phase 1)
Auto-populating, criteria-driven playlists with full iTunes 12 import. See
tests/test_round14.py (27 tests, incl. real captured blobs in
tests/smart_blobs.json).
- Criteria model + evaluator (
lintunes/smart.py) — recursiveSmartRule/SmartGroup/SmartLimit/SmartCriteria(JSON round-tripping), a sharedFIELD_REGISTRY(field → Track attr, type, operators) used by both evaluator and editor, andevaluate()(match all/any, nested groups, string/int/duration/rating/date/bool ops, relative "in the last N", and limit-by items/time/size with a "selected by" sort). - iTunes import — the binary "Smart Info"/"Smart Criteria" blobs are
decoded by a vendored, MIT-licensed parser (
lintunes/itunes_smart/, from github.com/cvzi/itunes_smartplaylist) and converted to our model byparse_itunes_smart(). Nested rule groups parse and evaluate correctly; blobs we can't represent (MediaKind/iCloud/etc.) flagunsupportedand keep the imported track snapshot. Validated against all 14 real smart playlists.lovedis deliberately dropped per user preference. - Manager (
library_manager.py) —create_smart_playlist/set_smart_criteria(undoable) /recompute_smart_playlist/recompute_all_smart; field-scoped, coalesced live recompute hooked into the track-edit/play/skip/add funnels (honours each playlist'slive_update); a no-op equality guard to avoid Syncthing churn; manual add/remove/reorder blocked on smart playlists.main.pyrecomputes on load;conflict_resolver.pytakes newest criteria (no track_id union) for smart. - GUI —
❧glyph on smart rows in the sidebar (with a rename-strip delegate), distinct context menu (New/Edit Smart Playlist), read-only track table for smart playlists, andSmartPlaylistEditorDialog(gui/smart_playlist_dialog.py): per-field rule rows, match all/any, limits, live-updating. New Smart Playlist on File menu (Ctrl+Alt+N).
Phase 2 TODO: nested-group editing UI in the dialog (imported nested playlists are currently shown read-only with a banner; they still update/play).
Round 8 (2026-06-19)
All four implemented in Round 8 (2026-06-19); see tests/test_round8.py.
-
the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist.
-
ability to edit the ID3 tags of multiple files at once and have it intelligently only modify only the id3 tags that the files share. Say we're editing all songs in an albumb: the song title isn't editable because it isn't the same between the tracks, but the album title would show and the artist. So we could make those changes to all songs at once. (iTunes-style: differing fields show a "Mixed" placeholder but stay editable; only fields you actually touch are written to all selected tracks.)
-
search, we need search. It only needs to appear in the library view. This should be a text box that takes up a third of that empty horizontal space between the tracks view and the top control panel. It should be a textbox that has in very light gray text that says "search". When you click into it that the "search" text disappears. As soon as you start typing it starts filtering the music. This should be compatible with the browser columns so the browser columns still show genres, artists and albums that match the search term. The search should search all fields for the search term(s). I could see this being really slow having to search all fields of all tracks at every keypress. So we can pause between key-presses if we want. So it's a search but really more like a filter. (250 ms debounce.)
-
I want support for custom start and end times of tracks like itunes has. We need to be able to import this data from how it's stored in the itunes library and we need to be able to respect it during playback. There should also be fields in the 'get info' screen that allows us to turn custom start/end times of the track on and off and set the value of how far into the song it starts/ends. This is a feature in itunes we're implementing. (Import was already wired; added Get Info fields + playback seek-to-start/stop-at-stop. Stored in library JSON only — no standard ID3 frame, same as iTunes.)
-
Search box over the library. (Round 8.)
Rounds 1–4 (2026-06-12)
- Spec 1–14 — full iTunes-replacement baseline:
- iTunes XML import with Mac→local location remap, play counts/ratings/dates
preserved, smart + system playlists skipped (
importers/itunes_importer.py). - Syncthing-friendly JSON storage, conflict-file merge on startup
(
storage/); data dir lives in the synced music folder. - iTunes-style layout: sidebar + track view; genre/artist/album browser (Ctrl+B); per-playlist columns/sort/manual order; playlist folders.
- PyQt6 GUI; playback (mp3/m4a/flac via Qt Multimedia); scrubbing timeline + transport buttons; Space/←/→ keys + MPRIS2 media keys.
- Drag/copy-paste tracks; drop-to-import files/folders; Get Info tag editing via mutagen.
- iTunes XML import with Mac→local location remap, play counts/ratings/dates
preserved, smart + system playlists skipped (
- Round 2 — art paste in Get Info; BPM tap button; guessed ratings dropped
- migrated; big-art window; 60fps EQ visualizer; cue-don't-play arrows; now-playing speaker icon.
- Round 3 — Preferences (
preferences.py, Edit ▸ Preferences, Ctrl+,); Last.fm scrobbling (lastfm.py, full-play-through only); theming (theme.py: 5 highlight colors, 3 UI scales); shuffle (walk-order); sidebar restructure (Library button + bottom art); MPRIS artUrl; painted transport glyphs; drag fix; app icon +install-desktop.sh. - Round 4 — drag chip + glowing drop line; Library button border; 8px BPM box; click-to-jump seek slider; vertical browser splitter; fixed-baseline visualizer (this also resolved the "vibrating bar bottoms" complaint).
Round 5 (2026-06-13) — UI polish + status bar + browser normalization
- Visualizer rounded corners — rounded gray panel + 1px
palette(mid)border, bars clipped to the rounded shape (gui/visualizer.py). - BPM button flat inside its box, matching the transport icons
(
gui/transport.py). - Bottom status bar — permanent totals readout
N tracks · DD:HH:MM:SS(trimmed leading units) for the visible track set; reflects browser genre/artist/album selection and playlist contents; transient import/scrobble/error messages still shown; height scales with UI size (gui/track_table.pyformat_total_time/tracks_changed/total_stats,gui/main_window.py,theme.pystatus_height). - Browser normalization — merge case variants (display the
most-tracks spelling; ties: more capitals, then alphabetical) for genres,
artists, albums; ignore leading "The" when sorting artists; case-insensitive
filtering so a canonical entry matches all variants (
gui/library_view.py). - Header bold bug —
setHighlightSections(False); column headers no longer bold when playback sets the current cell (gui/track_table.py). - Library button — light-gray rounded button, not bold
(
gui/sidebar.py). - Playlist title — name only; count/time moved to the status bar
(
gui/playlist_view.py). - Tests:
tests/test_round5.py(format_total_time, _canonical_values, _artist_sort_key).
2026-06-18 — top control panel tweaks
-
Faster tap-BPM commit — the tapped tempo now saves to the track 3s after the cursor leaves the button, down from 6s (
gui/transport.pyBpmButton.SAVE_DELAY_MS). -
Control panel redesign — bar height floored at
BAR_HEIGHT(84px, ~10% under the old ~95px) viasetMinimumHeight, giving the center column vertical slack; the title/artist block is centered with equal top/bottom borders (verified ~11px vs ~10px) using equaladdStretchabove and below, while the seek row stays pinned to the bottom so the timeline sits low. Key fix: the bpm box was moved out of the seek row into its own box on the far right (layout.addWidget(_box(...))). It was a 37px-tall box that inflated the timeline row, floating the 15px slider with a lopsided phantom gap below the artist; out of that row the seek row collapses to slider/label height so the stretches split evenly. Note:BAR_HEIGHTis a floor — setting it below the natural stacked height (~69px) has no effect. EQ visualizer panel background now matches the transport boxes (palette(alternate-base)instead ofwindow().darker(115)). (gui/transport.py,gui/visualizer.py.) Verified by offscreenQWidget.grab()render + widget-geometry measurement (gnome-screenshot is blocked under Wayland). -
Move data dir to
/run/media/trav/tummult/music/lintunes/once trav confirms the real import looks right (re-run import or copy./data, update config with--save-config). -
Run the real import on trav's library and eyeball the result (fixture in
./itunes-test-library/). -
Album art beyond now-playing (grid/album view); embedded tags only (decision: no parsing of iTunes' .itc artwork cache).
-
Search box over the library.
-
Background/threaded bulk file import (current import is synchronous).
-
Better duplicate handling on import-by-drop (currently matched by path).
-
Volume control in the transport bar (MPRIS exposes a fixed 1.0).
-
Smart playlists, if ever (skipped at import for now).
-
Wayland edge-drag resize — needs trav's verification; if still bad, try
lintunes -platform xcband switch the .desktop Exec if that fixes it.