v0.20.4: the song that wasn't there

trav couldn't play the first track of his playlist. Shuffle off gave him song
two; shuffle on gave him a random one. The player was right both times:
library.json named a song whose file was not on the device, and skipping an
unplayable file advances one slot. Tapping any song that *is* there plays
exactly that song, shuffle or not.

The file was missing because a gvfs-MTP mount can hold a phantom directory --
one it lists happily while the device has no such folder. Every write into it
fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it
never heals; only remounting clears it. That folder was new because Round 52's
retag moved the file under a new artist.

So a copy that raises OSError now costs that song, not the sync, and the song
is left out of the m3u and library.json. Aborting cost 2,400 songs for one
folder; counting it present anyway put a song in the manifest that isn't on
the device, which is the bug you could hear. The names come back in the
summary and a dialog rather than vanishing.

Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but
it walks every file on the device, and over MTP that is thousands of round
trips -- on the GUI thread it froze the window and GNOME offered to kill
LinTunes, which is how a sync got force-quit halfway through.

And the sync stopped going quiet at the end. "Album art 501/501" is emitted
before the last album, and then _write_index, _ship_buttons and
_prune_empty_dirs ran silently: 501 exists() stats over MTP, ~840 KB of
writes and a full tree walk, with the progress line frozen. They report now,
and _write_index reads Art/ in one listing instead of a stat per album.

andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly --
a detent adds to a pixel debt that a Choreographer callback pays off a
fraction per frame, so one detent eases to a stop and a fast spin blends into
one movement instead of teleporting a row at a time. Volume is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-14 13:25:15 -07:00
co-authored by Claude Opus 5
parent def7635a87
commit efa07b05c6
12 changed files with 457 additions and 36 deletions
+15
View File
@@ -347,6 +347,21 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
device diff, which had been re-copying every such file over MTP every sync.
`plan.stale` still carries the device's own spelling — that is what
`_delete_stale` unlinks 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 fails `EIO`
forever, because `mkdir(exist_ok=True)` sees the phantom and does nothing.
Only remounting clears it (`gio mount -u mtp://…` then `gio mount`). Two
rules came out of it (Round 55): a copy that raises `OSError` costs **that
song**, not the sync (it lands in the `unwritable` summary list), and the
song is then **left out of the m3u and `library.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_sync` is 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 on `Album art 501/501`, and
`_write_index` reads `Art/` in **one listing**, never a stat per album.
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +