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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>