Files
lintunes/tasks-done.md
T
travandClaude Opus 5 087c4bf103 v0.4.0: cast to a Chromecast from the Connections menu
The Device menu becomes Connections, with "Connect to Chromecast…" beside
the Rabbit sync. It opens a dialog that spins while it searches and lists
devices as they appear; picking one hands playback over, puts a small cast
glyph under the volume slider, and clicking that glyph disconnects.

Media-receiver model: lintunes serves the original file over the LAN on an
ephemeral port and the Chromecast decodes it itself. Bit-exact — no
transcode, no second lossy encode — and the device buffers for itself. The
cost is that lintunes is a remote control while connected: no local PCM, so
the visualizer shows the cast glyph instead of bars, and transport actions
land with about a second of round trip.

Player now walks its queue through a swappable PlaybackSink. It keeps
owning the queue, shuffle walk, start/stop times and the play-count and
scrobble bookkeeping, so casting counts plays and scrobbles exactly like
local playback. set_sink() carries track, position and playing-state both
ways, so connecting and disconnecting pick up mid-song. LocalSink stays in
player.py because the Player tests stub Qt Multimedia in that namespace.

The URL carries an opaque random token, never a path, so there is nothing
to traverse with; only the last few played tracks stay resolvable and the
whole map dies with the session. Range and HEAD are implemented because
the device seeks by re-requesting ranges and won't report a duration
without them.

Failure handling, verified against a real device: a dropped socket gets a
15s grace period, since pychromecast retries on its own and a Wi-Fi blip
heals itself. A real loss, another app taking the device, or a network
change falls back to local playback still playing, at the same position —
the sink reports the state lintunes last asked for rather than the IDLE
status a dying connection pushes just before it goes. Quitting stops the
device instead of leaving it fetching from a server that just died. The
~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a
status-bar message; the other 21,000+ MP3 and AAC files cast natively.

New dependency: pychromecast>=14.0.10, imported lazily so the app still
launches where it isn't installed (the menu item then explains the
install), and python_requires raised to >=3.11 to match its floor.

NOTE: the other machine needs `pip install -e .` before casting appears.

tests/test_round29.py: 67 tests; 440 pass overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:49:53 -04:00

241 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Done
### 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.
- [x] **`lintunes/cast/`** — `support.py` (format gate, source-address
selection, range parsing), `server.py` (token-addressed
`ThreadingHTTPServer` with Range/HEAD), `discovery.py` (`CastBrowser` behind
Qt signals), `sink.py` (`CastSink`), `controller.py` (session lifecycle +
its own `SleepInhibitor`).
- [x] **`Player` sink split** — new `PlaybackSink` base with `LocalSink` and
`CastSink`; `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.
- [x] **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.
- [x] **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.
- [x] **New dependency: `pychromecast>=14.0.10`**, imported lazily so the app
still launches where it isn't installed; `python_requires` raised to >=3.11
(its floor). The menu item explains the `pip install -e .` when missing.
### Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite)
- [x] Confirm Last.fm scrobbling works
- [x] Volume slider between the visualizer and the timeline
- [x] Preferences: per-area color adjustments (background, button
round-rects, now-playing text + its backing, tracklist stripe gray), each
with a live black↔white slider
- [x] Visualizer: discrete light-gray-on-light-gray mode
- [x] Cut/copy/paste tracks (Ctrl+X/C/V); paste inserts above the selected
track, not at the end
- [x] Auto-scroll the track list when dragging a track above/below the
visible area
- [x] Move data dir to `/run/media/trav/tummult/music/lintunes/` +
relative track paths (see Round 15)
- [x] 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.
- [x] **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.
- [x] **Auto-merge + backups + summaries**`storage/conflict_resolver.py` now
backs up *both* sides into `<data>/.resolved/<timestamp>/{original,incoming}/`
before merging, returns `list[ConflictSummary]`, and adds `restore_backup()`.
Conflicts are resolved at startup **and live** (via the watcher).
- [x] **Alert UI**`gui/conflict_dialog.py` `ConflictSummaryDialog` (scrollable
summary, Open backup folder, Restore pre-merge backup), shown modeless from a
status-bar notice so a merge never interrupts playback. Wired in `main_window`
(`_on_library_reloaded` preserves 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).
- [x] **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 the `json_storage` boundary (`save_tracks` /
`load_library`). `Track.location` stays 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-machine `music_root` config. Absolute
paths in older `library.json` files still load (back-compat).
- [x] **Final import + go live** — fresh import of the current `iTunes Library.xml`
into 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 to `git.autonomic.zone:2222/trav/lintunes` so 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`).
- [x] **Criteria model + evaluator** (`lintunes/smart.py`) — recursive
`SmartRule`/`SmartGroup`/`SmartLimit`/`SmartCriteria` (JSON round-tripping),
a shared `FIELD_REGISTRY` (field → Track attr, type, operators) used by both
evaluator and editor, and `evaluate()` (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).
- [x] **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 by
`parse_itunes_smart()`. Nested rule groups **parse and evaluate** correctly;
blobs we can't represent (MediaKind/iCloud/etc.) flag `unsupported` and keep
the imported track snapshot. Validated against all 14 real smart playlists.
`loved` is deliberately dropped per user preference.
- [x] **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's `live_update`); a
no-op equality guard to avoid Syncthing churn; manual add/remove/reorder
blocked on smart playlists. `main.py` recomputes on load;
`conflict_resolver.py` takes newest criteria (no track_id union) for smart.
- [x] **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, and `SmartPlaylistEditorDialog`
(`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.
- [x] 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.
- [x] 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.)_
- [x] 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.)_
- [x] 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.)_
- [x] Search box over the library. _(Round 8.)_
### Rounds 14 (2026-06-12)
- **Spec 114** — 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.
- **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
- [x] **Visualizer rounded corners** — rounded gray panel + 1px `palette(mid)`
border, bars clipped to the rounded shape (`gui/visualizer.py`).
- [x] **BPM button** flat inside its box, matching the transport icons
(`gui/transport.py`).
- [x] **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.py` `format_total_time`/`tracks_changed`/`total_stats`,
`gui/main_window.py`, `theme.py` `status_height`).
- [x] **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`).
- [x] **Header bold bug**`setHighlightSections(False)`; column headers no
longer bold when playback sets the current cell (`gui/track_table.py`).
- [x] **Library button** — light-gray rounded button, not bold
(`gui/sidebar.py`).
- [x] **Playlist title** — name only; count/time moved to the status bar
(`gui/playlist_view.py`).
- [x] Tests: `tests/test_round5.py` (format_total_time, _canonical_values,
_artist_sort_key).
### 2026-06-18 — top control panel tweaks
- [x] **Faster tap-BPM commit** — the tapped tempo now saves to the track 3s
after the cursor leaves the button, down from 6s (`gui/transport.py`
`BpmButton.SAVE_DELAY_MS`).
- [x] **Control panel redesign** — bar height floored at `BAR_HEIGHT` (84px,
~10% under the old ~95px) via `setMinimumHeight`, giving the center column
vertical slack; the title/artist block is centered with **equal** top/bottom
borders (verified ~11px vs ~10px) using equal `addStretch` above 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_HEIGHT` is 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 of
`window().darker(115)`). (`gui/transport.py`, `gui/visualizer.py`.) Verified
by offscreen `QWidget.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 xcb` and switch the .desktop Exec if that fixes it.