28 Commits

Author SHA1 Message Date
663d5e5a08 v0.1.4: playback-control provenance log
LinTunes has spontaneously resumed playback for ~4s (then paused) four
times while trav was away — suspected phantom media-key events from the
Audioengine USB DAC's HID 'keyboard', but the pathway (MPRIS vs the
focused-window key filter) is unproven. Every control path now drops a
timestamped line in ~/.cache/lintunes/control-events.log: MPRIS Player
methods, the media-key eventFilter (with source input device where the
compositor exposes it), and Player.toggle_play/pause with position. New
lintunes/eventlog.py, 1MB rotation, never raises; conftest autouse
fixture keeps tests off the real log file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:21 -04:00
a50c39fa43 v0.1.3: start time honored when replaying the already-loaded track
QMediaPlayer.setSource() no-ops on an unchanged URL, so replaying the
track that was already the loaded source never re-fired LoadedMedia and
the armed _pending_start_ms was silently dropped — a freshly edited
start time appeared to save but playback began at 0:00. _load_current
now detects the unchanged source and stop()+seeks directly (a forced
clear+reload was tried and races the FFmpeg backend, snapping the seek
back to 0). previous()-restart likewise rewinds to the custom start
time instead of 0:00.

Verified live via an offscreen GUI harness: replay-while-playing,
replay-after-EndOfMedia, and cleared-start-time replay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:28:21 -04:00
f94a66963b v0.1.2: transport buttons fill their bubble as equal tap targets
Prev/play/next (and shuffle) used to hug their ~20px glyphs in the middle
of the 62px rounded box, so most of the bubble was dead space. _box() now
takes split=True: the buttons tile the bubble interior in equal shares,
expanding to the (tightened) margins with the glyph centered in each.
Minimum button widths keep the bubbles at their old footprint — no bigger,
no smaller. Round 21 tests cover the equal split, vertical fill, edge
spans, and the preserved footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:03:54 -04:00
332b47a018 v0.1.1: perf timings + single-save tag writes + coalesced browser rebuild
Diagnosing the Ctrl+I save hang on the Debian machine (GUI thread blocks
past mutter's 5s check-alive):

- lintunes/perf.py: timed() context manager logging wall-clock ms at INFO
  on lintunes.perf — instruments tag/artwork saves, file moves,
  edit_track(s)_fields, the browser rebuild, smart recompute, and the
  debounced JSON flush, so the slow machine can tell us which stage
  eats the time (journalctl --user or a terminal run).
- tagging.write_tags: ONE parse + ONE save per edit. grouping/
  compilation/bpm ride the main save via registered Easy keys
  (GRP1/TCMP/TBPM on EasyID3, cpil on EasyMP4) instead of
  _write_extra_tags re-parsing and re-saving the audio file.
- LibraryView: browser rebuilds coalesce through a 0ms single-shot
  timer — an N-track Get Info edit rebuilds the genre/artist/album
  cascade once instead of N times over the whole library.

Moving the writes off the GUI thread is deliberately deferred until the
Debian timings say which stage dominates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:25:56 -04:00
2d02ccefd6 CLAUDE.md: every round ends with commit AND push (keep machines synced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:31:37 -04:00
1bf346229c Task board: Round 19 section; versioning convention in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
502a6106f4 v0.1.0: version in the status bar + one-click self-update via git pull
__version__ in lintunes/__init__.py (setup.py regex-reads it) shows
bottom-left via the new VersionButton; the tooltip carries the git short
hash so machines on the same version but different commits are
distinguishable. The new Updater fetches upstream 10s after launch and
every 4h on daemon threads (lastfm.py pattern); commits behind put a *
on the button, and clicking it confirms, runs `git pull --ff-only`,
quits cleanly (library flush + player shutdown), and re-execs
`python -m lintunes.main` on the new code. Positional file args are
stripped from the re-exec so they don't re-import as duplicates. Pull
failures surface the git error: line in the status bar and leave the
running app untouched; outside a git checkout the button is a plain
label.

Also fixes a Round-5 regression found while verifying: the totals label
was a permanent status-bar widget with stretch=1, which squeezed the
transient-message area to zero width — every showMessage (scrobbles,
tag-write errors, import status) has been invisible since. Both
readouts are now non-permanent widgets, so a transient message
temporarily replaces them and they return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
407c5669c5 BPM tap button averages the whole session, not the last 8 taps
TapTempo kept a rolling window of 8 taps, so a long tapping session
chased the most recent taps instead of converging on the overall tempo
(trav noticed the estimate never settled). Every tap since the session
started now counts; a >2.5s gap still begins a fresh session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
405250dc4e Task board: Round 18 section, equalizer parked with design note
CLAUDE.md's never-move rule rewritten for the Round 18 rename-move
feature; TASKS.md New backlog folded into a checked-off Round 18
section (plus trav's by-eye verify list). The equalizer item moves to
Parked/deferred with trav's bass/mid/treble idea and the QMediaPlayer
no-effects-hooks blocker recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:05:47 -04:00
40b951ca1a Renaming artist/album moves the file: music folder stays organized
Editing artist/album_artist/album now relocates the file to its new
<music_folder>/Music/Artist/Album/ home (folders created as needed,
"Unknown Artist" fallback via the same organized_destination the
importer uses), deliberately relaxing the old never-move rule:

- LibraryManager.organize_root() is the shared definition of the
  managed tree; MainWindow._music_import_dir delegates to it.
- Only files already inside the organize root are managed; external
  files get their tags edited but are never pulled into the tree.
- The move rides the existing undo Command maps as a "location" field:
  Ctrl+Z moves the file back, redo re-moves, both best-effort (a failed
  physical move drops "location" so memory tracks the real file).
- A failed move applies the edit, keeps the old path, and reports via
  the new file_move_failed signal (status bar), mirroring
  tag_write_failed. Collisions get the importer's " 1" suffix.
- Directories left empty are pruned up to (never including) the root.
- conflict_resolver._merge_track_fields now carries "location" with
  the newest-date_modified rule, so a rename-move on one machine
  propagates through both the sync-conflict merge and the live
  reload_from_disk reconcile on the other.
- The on-disk basename is kept verbatim (no re-sanitizing), so files
  named on macOS aren't gratuitously renamed by a tag edit.

Same-filesystem rename keeps the player's open fd valid, so moving the
currently-playing track is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:59:59 -04:00
7e8359d872 File drops from the file manager insert at the drop position
The table already computed and emitted the drop row for external URL
drops, but PlaylistView._on_files_dropped threw it away, so imported
files always appended. Thread it through: playlist_view.files_dropped
gains a row slot (None = append), MainWindow.import_files and
file_importer.import_paths take an optional position and hand it to
add_tracks_to_playlist, which already supported positional insert.
Files already in the library dedup to the existing track and insert at
the drop position without duplicating the library entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:54:54 -04:00
0d8c9dd033 Visualizer: gray-mode color slider + mode persists across launches
- New prefs: visualizer_mode (on/dim/off, saved on every click) and
  color_visualizer_gray (None = keep the derived alternateBase look).
- VisualizerWidget takes prefs (optional, so existing call sites/tests
  keep working); restores its mode at startup and validates junk values.
- New "Visualizer (gray mode):" row in the Preferences color sliders;
  theme.default_gray mirrors the widget's 0.88-lightness derivation so
  an untouched slider shows the current appearance.
- TransportBar.refresh_theme() repaints the visualizer so slider drags
  preview live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:52:59 -04:00
d46374967d Fix silent resume after walking away: seek-in-place re-primes the sink
Rediagnosed (was thought Bluetooth-specific): on the Debian 13 machine,
resuming after the machine sat idle plays nothing even though decoding
runs (visualizer moves) — the OS suspends the audio sink and it comes
back dead until a seek re-primes it. Resuming after a pause of 30s+ now
does a seek-in-place before play(), automating the manual "rewind
slightly" workaround without losing the position. The volume re-apply
from the earlier fix stays (harmless, covers device swaps).

Also fold the new backlog items into the TASKS.md board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:56:11 -04:00
b69a25fa51 Album art download: one-click apply to every song on the album
The confirmation dialog gains a "Use for All N Songs in Album" button
(shown when the library's album has more songs than the selection); it
embeds the chosen art into every track matching the album, not just the
right-clicked ones. Album membership matches case-insensitively on album
name + album artist (falling back to track artist), the same grouping the
search already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:54:24 -04:00
649302551d tysm fable 2026-07-02 12:44:58 -04:00
8dc33fa65e Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)
Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:36:11 -04:00
9575f0400f Live multi-machine sync: watch files, reconcile, alert on conflicts
Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.

- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
  emits a single `changed`; the manager decides if it was external.
- library_manager: `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. `flush()` records each file's (mtime, size)
  so our own writes are never mistaken for an external change. New signals
  library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
  before merging, return list[ConflictSummary], add restore_backup(); runs at
  startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
  shows it from a status-bar notice and preserves scroll+selection on reload.

Tests: tests/test_round16.py (9) + full suite green (225).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:55:45 -04:00
94e7c0713b README: install routes (pip/apt/venv) + screenshot
Document three ways to get the dependencies since Debian's system pip is locked
down (PEP 668 externally-managed-environment): pip, apt (no pip), or a venv, with
the note that `python3 -m lintunes.main` runs the app without pip or the
`lintunes` command at all. Make the second-machine steps install-agnostic and add
the app screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:51:48 -04:00
8237fe0fd2 README: document second-machine (Syncthing) setup
Add a "Running on a second machine" section: clone + install, point the
per-machine ~/.config/lintunes/config.json at the synced folder via --save-config
(no re-import needed since paths are stored relative to the data dir), and run
packaging/install-desktop.sh to install + pin the launcher. Note the desktop
launcher follows the saved config, and add smart playlists + multi-machine sync
to the feature list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:00:17 -04:00
2c5b5a575f Store track paths relative to the data dir for multi-machine sync
The library is going live as the canonical store, synced to a second machine
via Syncthing. Track locations were stored absolute and only remapped at import,
so on a second machine (where Syncthing mounts the folder at a different path)
every location would break.

- lintunes/paths.py: to_relative/to_absolute. Locations are stored relative to
  the data dir and resolved back on load, applied only at the json_storage
  boundary (save_tracks/load_library). Track.location stays absolute in memory,
  so the player, tagging, and art code are unchanged. The data dir and the music
  move together inside one synced tree, so paths resolve wherever it's mounted —
  no per-machine music_root config. Absolute paths in older library.json files
  still load (back-compat).
- tests/test_round15.py: helper round-trips, back-compat, and a machine-2
  scenario (save under root A, load the copied tree under root B).
- TASKS.md/tasks-done.md: mark the data-dir move + real import done; log the
  benign exit-time Qt/FFmpeg teardown segfault.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:57 -04:00
7e8266c14c Add smart playlists with full iTunes 12 import (Phase 1)
Auto-populating, criteria-driven playlists that import faithfully from iTunes.

- lintunes/smart.py: recursive SmartCriteria/SmartGroup/SmartRule/SmartLimit
  model (JSON round-tripping), a shared FIELD_REGISTRY used by both the
  evaluator and the editor, and evaluate() (match all/any, nested groups,
  string/int/duration/rating/date/bool ops, "in the last N", limit by
  items/time/size). Null play/skip dates are treated as the distant past,
  matching iTunes.
- iTunes import via a vendored MIT-licensed binary parser
  (lintunes/itunes_smart/, from cvzi/itunes_smartplaylist). Nested groups
  parse and evaluate; blobs we can't represent (MediaKind/iCloud/etc.) flag
  unsupported and keep the imported snapshot. "loved" is dropped per user pref.
- library_manager: create/set/recompute smart playlists (undoable), field-scoped
  coalesced live recompute hooked into the edit/play/skip/add funnels, a no-op
  equality guard to avoid Syncthing churn, and manual-edit guards. main.py
  recomputes on load; conflict_resolver keeps newest criteria for smart lists.
- GUI: ❧ glyph painted in the sidebar branch column, read-only track table for
  smart playlists, New/Edit Smart Playlist menus, and SmartPlaylistEditorDialog
  (per-field rule rows, match all/any, limits, live updating).

Tests: tests/test_round14.py (real captured blobs in tests/smart_blobs.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:39 -04:00
b6d1805962 Cut removes immediately; scrollbar click-to-jump
Ctrl-X now removes the track from the playlist right away (so it visibly
leaves) and a paste re-inserts it at the target — replacing the earlier
deferred-move design (dropped move_tracks_between_playlists and the MIME
cut flag). A cut from the library is still just a copy. Separately, a
left-click on any scrollbar trough now jumps to that spot instead of
paging, via an app-wide ClickToJumpScrollStyle proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:12:27 -04:00
3e43c81302 Add drag auto-scroll near the list edges
While a track drag hovers within 28px of the track list's top/bottom
edge, the list scrolls that way (40ms timer) so the user can drop onto
off-screen rows. Qt's built-in autoscroll never fires because our
dragMoveEvent accepts the event itself, so this is driven manually; the
drop line refreshes as content scrolls under a stationary cursor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:58:30 -04:00
2af0d91a76 Add Ctrl-X cut and paste-above-selection
Cut (Ctrl-X) marks the selection on the clipboard with a `cut` flag; a
paste of a cut from a real playlist is a *move* (single-undo across
playlists via move_tracks_between_playlists; same-playlist reorders),
while a copy — or a cut from the library, which has nothing to remove —
just adds. Paste now inserts *above the selected track* rather than
appending. Cut/Paste added to the track context menu; parse_tracks_mime
now tolerates a None/empty clipboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:55:38 -04:00
1dd128b7f4 Add per-area color preference sliders
Five grayscale (black->white) sliders in Preferences that update live:
outer-chrome background (QPalette.Window), the rounded transport button
boxes, the now-playing panel, the now-playing text, and the row stripes
(QPalette.AlternateBase). Defaults are None ("inherit current") so nothing
changes until the user drags. Decouples the button boxes from the stripe
color (they used to share palette alternate-base). Live preview via
Preferences.set_live (no disk write); persisted ~0.4s after the drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:41:49 -04:00
bb31e9e5cc Add volume slider to the transport bar
A slim horizontal master-volume slider in its own slot between the
visualizer and the timeline (TASKS.md). Perceptual->linear gain so it
ramps like iTunes; persists to preferences.json (debounced so it doesn't
thrash the theme). Clicking jumps to the spot via a new ClickJumpSlider
base extracted from SeekSlider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:21:00 -04:00
45bc8518a3 Add CLAUDE.md
Codebase guide for future Claude Code sessions: commands, the
storage→model→manager→GUI architecture, and Qt/Wayland gotchas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:13:06 -04:00
f6d35fa594 Import existing LinTunes project
Snapshot of the existing codebase before working through the TASKS.md
backlog. Real library data (data/) and the iTunes import fixture
(itunes-test-library/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:12:01 -04:00