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. 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 `plan.stale` still carries the device's own spelling — that is what
`_delete_stale` unlinks by. `_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 Since Round 50 the app exists: `andtunes/app/` is plain Java against the
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 → Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 + zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
+4 -1
View File
@@ -11,7 +11,10 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
`Library.group()` keyed albums case-folded but artists raw-case, so one artist `Library.group()` keyed albums case-folded but artists raw-case, so one artist
spelled two ways on one album ("RJD2" / "Rjd2") took the whole library down spelled two ways on one album ("RJD2" / "Rjd2") took the whole library down
with `Couldn't read library.json`. Both maps fold case now, and `Artist` has a with `Couldn't read library.json`. Both maps fold case now, and `Artist` has a
`key` the way `Album` always did.** What's left is the *Parked* list, pulled in `key` the way `Album` always did. 0.2.2 (round 55, 2026-09-14) reversed the
wheel in lists and made it scroll smoothly (`WheelScroll` — a pixel debt paid
off per frame, not a row per detent); volume on the other screens is
unchanged.** What's left is the *Parked* list, pulled in
as trav finds he needs it. Built with as trav finds he needs it. Built with
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see `python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round `README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
@@ -52,6 +52,7 @@ public class ListActivity extends BaseActivity {
private String mode, id; private String mode, id;
private TextView titleBar; private TextView titleBar;
private ListView list; private ListView list;
private WheelScroll wheel;
private EditText searchBox; private EditText searchBox;
private final RowAdapter adapter = new RowAdapter(); private final RowAdapter adapter = new RowAdapter();
private final ArrayList<Row> rows = new ArrayList<>(); private final ArrayList<Row> rows = new ArrayList<>();
@@ -84,6 +85,7 @@ public class ListActivity extends BaseActivity {
list.setBackgroundColor(Ui.WHITE); list.setBackgroundColor(Ui.WHITE);
list.setAdapter(adapter); list.setAdapter(adapter);
list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position))); list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position)));
wheel = new WheelScroll(list);
column.addView(list, new LinearLayout.LayoutParams( column.addView(list, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
@@ -95,6 +97,7 @@ public class ListActivity extends BaseActivity {
@Override protected void onDestroy() { @Override protected void onDestroy() {
Library.forget(onLoaded); Library.forget(onLoaded);
wheel.stop();
super.onDestroy(); super.onDestroy();
} }
@@ -103,8 +106,9 @@ public class ListActivity extends BaseActivity {
adapter.notifyDataSetChanged(); // re-mark the playing row adapter.notifyDataSetChanged(); // re-mark the playing row
} }
/** Wheel up moves the list down, the way pushing the page up does. */
@Override protected void onWheel(int dir) { @Override protected void onWheel(int dir) {
list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP)); wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP));
} }
private String defaultTitle() { private String defaultTitle() {
@@ -0,0 +1,68 @@
package me.teafry.andtunes;
import android.view.Choreographer;
import android.widget.ListView;
/**
* Smooth scrolling for the R1's wheel.
*
* <p>The wheel arrives as discrete detents, and moving a whole row per detent
* reads as a teleport — the list jumps a track at a time instead of gliding
* the way a finger drag does. So a detent doesn't scroll: it adds to a pixel
* <em>debt</em>, and a Choreographer callback pays a fraction of that debt off
* every frame. One detent eases to a stop; a fast spin piles debt up faster
* than it drains and becomes one continuous movement.
*
* <p>Frame-driven rather than animated per detent, because a new detent has to
* blend into the motion already underway — restarting an animation each time
* is what makes wheel scrolling stutter.
*/
final class WheelScroll implements Choreographer.FrameCallback {
/** Fraction of the outstanding debt paid per frame: the easing curve. */
private static final float FRICTION = 0.28f;
/** Below this the remainder is paid in one go rather than halved forever. */
private static final float SETTLE_PX = 1.5f;
/** How far one detent travels. Deliberately not a row height. */
static final int STEP_DP = 52;
private final ListView list;
private float debt;
private boolean running;
WheelScroll(ListView list) {
this.list = list;
}
/** One detent, {@code px} signed pixels in the direction to travel. */
void push(float px) {
debt += px;
if (!running) {
running = true;
Choreographer.getInstance().postFrameCallback(this);
}
}
@Override public void doFrame(long frameTimeNanos) {
float step = Math.abs(debt) <= SETTLE_PX ? debt : debt * FRICTION;
int px = Math.round(step);
if (px != 0) {
list.scrollListBy(px);
debt -= px; // what actually moved, so hitting either end ends it
} else {
debt = 0;
}
if (Math.abs(debt) >= 0.5f) {
Choreographer.getInstance().postFrameCallback(this);
} else {
debt = 0;
running = false;
}
}
/** Drop any motion still owed (the screen is going away). */
void stop() {
debt = 0;
running = false;
Choreographer.getInstance().removeFrameCallback(this);
}
}
+2 -2
View File
@@ -26,8 +26,8 @@ import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
VERSION_NAME = "0.2.1" VERSION_NAME = "0.2.2"
VERSION_CODE = 3 VERSION_CODE = 4
PACKAGE = "me.teafry.andtunes" PACKAGE = "me.teafry.andtunes"
MIN_SDK = 26 MIN_SDK = 26
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.20.3" __version__ = "0.20.4"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version_name": "0.2.1", "version_code": 3} {"version_name": "0.2.2", "version_code": 4}
+99 -18
View File
@@ -20,6 +20,7 @@ feeds Auxio, and it stays until andTunes can actually play a song.
import json import json
import os import os
import shutil
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib import resources from importlib import resources
@@ -34,8 +35,8 @@ from . import art as art_mod
from . import layout from . import layout
from . import plays as plays_mod from . import plays as plays_mod
from .layout import ( from .layout import (
BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, ART_DIR, BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key,
assert_inside, media_relpath, playlist_relpath, art_relpath, assert_inside, media_relpath, playlist_relpath,
) )
from .manifest import build_manifest from .manifest import build_manifest
@@ -284,6 +285,57 @@ class _Cancelled(Exception):
"""Internal: unwinds _run when cancel() was requested.""" """Internal: unwinds _run when cancel() was requested."""
class AndTunesPlanWorker(QObject):
"""`plan_andtunes_sync` on a daemon thread, because planning reads MTP.
The planner is pure and writes nothing, but it walks every file on the
device, and over gvfs-MTP that is thousands of round trips — several
seconds on trav's 2,400-song library. On the GUI thread that is a frozen
window, and GNOME offers to kill the app: "lintunes is not responding" is
how a sync got force-quit halfway through. `disk_usage` rides along
because it is one more MTP call the caller needed before deciding.
"""
planned = pyqtSignal(object, object) # AndTunesPlan, free bytes
failed = pyqtSignal(str)
def __init__(self, playlists, tracks, root, device, *,
copy_art=True, data_dir=None, parent=None):
super().__init__(parent)
self._args = (playlists, tracks, root, device)
self._copy_art = copy_art
self._data_dir = data_dir
self._busy = False
def busy(self) -> bool:
return self._busy
def start(self):
if self._busy:
return
self._busy = True
threading.Thread(target=self._run_guarded, daemon=True).start()
def _run_guarded(self):
try:
self._run()
finally:
self._busy = False
def _run(self):
playlists, tracks, root, device = self._args
try:
plan = plan_andtunes_sync(playlists, tracks, root, device,
copy_art=self._copy_art,
data_dir=self._data_dir)
free = shutil.disk_usage(device.root).free if device else 0
except OSError as e:
name = device.name if device else "device"
self.failed.emit(f"Couldn't read the {name}: {e}")
return
self.planned.emit(plan, free)
class AndTunesSyncWorker(QObject): class AndTunesSyncWorker(QObject):
"""Runs an AndTunesPlan on a daemon thread, reporting through signals. """Runs an AndTunesPlan on a daemon thread, reporting through signals.
@@ -343,6 +395,7 @@ class AndTunesSyncWorker(QObject):
self._done = 0 self._done = 0
self._plays = 0 # tracks whose play counts came back self._plays = 0 # tracks whose play counts came back
self._unconverted = 0 # conversions ffmpeg couldn't do self._unconverted = 0 # conversions ffmpeg couldn't do
self._unwritable: list[tuple[str, str]] = [] # (song, why) per refusal
try: try:
self._check_cancel() self._check_cancel()
@@ -369,6 +422,7 @@ class AndTunesSyncWorker(QObject):
"plays": self._plays, "plays": self._plays,
"refused": len(plan.refused), "refused": len(plan.refused),
"unconverted": self._unconverted, "unconverted": self._unconverted,
"unwritable": list(self._unwritable),
}) })
except _Cancelled: except _Cancelled:
self._drop_partial() self._drop_partial()
@@ -481,19 +535,36 @@ class AndTunesSyncWorker(QObject):
self._emit(label) self._emit(label)
continue continue
dest = plan.root / item.rel dest = plan.root / item.rel
dest.parent.mkdir(parents=True, exist_ok=True) # One song the device won't take is one song, not the sync. A
self._dest = dest # gvfs-MTP mount can hold a *phantom* directory — one it lists but
# Manual chunked copy: byte-accurate progress, and no copystat # the device doesn't have — and every write into it fails EIO
# (gvfs-MTP rejects it). # forever, since mkdir(exist_ok=True) sees the phantom and does
with fsrc, open(dest, "wb") as fdst: # nothing. Aborting here cost trav 2,400 songs for one folder; and
while True: # counting the track present anyway (the older sin) put a song in
chunk = fsrc.read(CHUNK) # library.json that isn't on the device, which the app then skips
if not chunk: # mid-playlist. So: report it, and leave it out of the manifest.
break try:
fdst.write(chunk) dest.parent.mkdir(parents=True, exist_ok=True)
self._done += len(chunk) self._dest = dest
self._emit(label) # Manual chunked copy: byte-accurate progress, and no copystat
self._check_cancel() # (gvfs-MTP rejects it).
with fsrc, open(dest, "wb") as fdst:
while True:
chunk = fsrc.read(CHUNK)
if not chunk:
break
fdst.write(chunk)
self._done += len(chunk)
self._emit(label)
self._check_cancel()
except OSError as e:
fsrc.close()
self._drop_partial()
self._unwritable.append((track_display(item.track),
e.strerror or str(e)))
self._done += item.size
self._emit(label)
continue
self._dest = None self._dest = None
self._present.add(item.track_id) self._present.add(item.track_id)
@@ -535,7 +606,9 @@ class AndTunesSyncWorker(QObject):
with holes in it. with holes in it.
""" """
plan = self._plan plan = self._plan
for entry in plan.playlists: total = len(plan.playlists)
for i, entry in enumerate(plan.playlists, start=1):
self._emit(f"Writing playlist {i}/{total}")
rows = [row for row, tid in zip(entry.entries, entry.track_ids) rows = [row for row, tid in zip(entry.entries, entry.track_ids)
if tid in self._present] if tid in self._present]
dest = plan.root / entry.rel dest = plan.root / entry.rel
@@ -544,9 +617,16 @@ class AndTunesSyncWorker(QObject):
# Which covers are on the device right now, rather than which ones # Which covers are on the device right now, rather than which ones
# this run wrote — so a cancelled sync still points at art an earlier # this run wrote — so a cancelled sync still points at art an earlier
# sync left behind. # sync left behind. One listing of Art/, not a stat per album: over
# MTP that was 500-odd round trips with the progress line frozen on
# the last album, which reads exactly like a hang.
self._emit("Writing the library")
try:
on_device = {p.name for p in (plan.root / ART_DIR).iterdir()}
except OSError:
on_device = set()
have_art = {key for key in plan.albums have_art = {key for key in plan.albums
if (plan.root / art_relpath(key)).exists()} if Path(art_relpath(key)).name in on_device}
entries = [(item.track, item.rel, entries = [(item.track, item.rel,
art_relpath(k) if (k := album_key(item.track)) in have_art art_relpath(k) if (k := album_key(item.track)) in have_art
else None) else None)
@@ -568,6 +648,7 @@ class AndTunesSyncWorker(QObject):
file costs a default image, not the sync. file costs a default image, not the sync.
""" """
plan = self._plan plan = self._plan
self._emit("Tidying up")
folder = assert_inside(plan.root, plan.root / BUTTONS_DIR) folder = assert_inside(plan.root, plan.root / BUTTONS_DIR)
for src in default_buttons(): for src in default_buttons():
dest = folder / src.name dest = folder / src.name
+39 -12
View File
@@ -167,6 +167,7 @@ class MainWindow(QMainWindow):
self._sync_cancel.clicked.connect(self._confirm_cancel_sync) self._sync_cancel.clicked.connect(self._confirm_cancel_sync)
self.statusBar().addPermanentWidget(self._sync_cancel) self.statusBar().addPermanentWidget(self._sync_cancel)
self._andtunes_worker = None self._andtunes_worker = None
self._andtunes_planner = None
self._install_worker = None self._install_worker = None
self._sync_device_name = "" self._sync_device_name = ""
# Export shares the status-bar progress widgets with device sync — # Export shares the status-bar progress widgets with device sync —
@@ -465,8 +466,8 @@ class MainWindow(QMainWindow):
def _busy_worker(self): def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets.""" """The transfer currently owning the status-bar progress widgets."""
for worker in (self._andtunes_worker, self._export_worker, for worker in (self._andtunes_planner, self._andtunes_worker,
self._url_worker): self._export_worker, self._url_worker):
if worker is not None and worker.busy(): if worker is not None and worker.busy():
return worker return worker
return None return None
@@ -548,17 +549,26 @@ class MainWindow(QMainWindow):
"Pick them in Connections → Rabbit Sync Settings…") "Pick them in Connections → Rabbit Sync Settings…")
return return
# Planning walks the whole device over MTP — off the GUI thread, or
# the window freezes and GNOME offers to kill it mid-sync.
root = andtunes_layout.root_for(device) root = andtunes_layout.root_for(device)
try: self._andtunes_planner = andtunes_sync.AndTunesPlanWorker(
plan = andtunes_sync.plan_andtunes_sync( playlists, self._manager.library.tracks, root, device,
playlists, self._manager.library.tracks, root, device, copy_art=bool(self._prefs.device_sync.get("copy_art", True)),
copy_art=bool(self._prefs.device_sync.get("copy_art", True)), data_dir=self._manager.data_dir, parent=self)
data_dir=self._manager.data_dir) self._andtunes_planner.planned.connect(self._on_andtunes_planned)
free = shutil.disk_usage(device.root).free self._andtunes_planner.failed.connect(self._on_sync_failed)
except OSError as e: self._sync_device_name = device.name
QMessageBox.warning(self, "Sync failed", self._sync_label.setText(f"Checking the {device.name}")
f"Couldn't read the {device.name}: {e}") self._sync_progress.setRange(0, 0) # busy bar: no total to show yet
return self._sync_label.show()
self._sync_progress.show()
self._andtunes_planner.start()
def _on_andtunes_planned(self, plan, free):
device = plan.device
playlists = plan.playlists
self._hide_sync_widgets()
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available(): if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
conversions = plan.conversions conversions = plan.conversions
names = "\n".join(f" • {device_sync.track_display(i.track)}" names = "\n".join(f" • {device_sync.track_display(i.track)}"
@@ -618,12 +628,29 @@ class MainWindow(QMainWindow):
msg += f", {summary['refused']} left out (can't play there)" msg += f", {summary['refused']} left out (can't play there)"
if summary.get("unconverted"): if summary.get("unconverted"):
msg += f", {summary['unconverted']} couldn't be converted" msg += f", {summary['unconverted']} couldn't be converted"
unwritable = summary.get("unwritable") or []
if unwritable:
msg += f", {len(unwritable)} the device wouldn't take"
if summary.get("plays"): if summary.get("plays"):
msg += f"; play counts back for {summary['plays']} song(s)" msg += f"; play counts back for {summary['plays']} song(s)"
# The journal landed in the data dir; fold it in now rather than # The journal landed in the data dir; fold it in now rather than
# waiting for the file watcher. # waiting for the file watcher.
self._manager.reload_from_disk() self._manager.reload_from_disk()
self.statusBar().showMessage(msg, 10000) self.statusBar().showMessage(msg, 10000)
if unwritable:
# Worth a dialog: these songs are *not* in the library the app
# reads, so they'd otherwise just be quietly absent from the
# playlist. A stale gvfs mount is the usual cause.
names = "\n".join(f" • {name} — {why}" for name, why in unwritable[:8])
more = (f"\n … and {len(unwritable) - 8} more"
if len(unwritable) > 8 else "")
QMessageBox.warning(
self, "Some songs wouldn't copy",
f"The {summary['device']} refused "
f"{len(unwritable)} song(s):\n\n{names}{more}\n\n"
"They've been left out of the library on the device, so the "
"app won't list a song it can't play. Unplugging the "
"device and plugging it back in usually clears this.")
# ---- connections: install the andTunes app ---- # ---- connections: install the andTunes app ----
+37
View File
@@ -1,5 +1,42 @@
## Done ## Done
### Round 55 (2026-09-14) — the song that wasn't there (v0.20.4, andTunes 0.2.2)
trav couldn't play the first track of his playlist: shuffle off gave him song
2, 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. Verified on the Rabbit: tapping any song
that *is* there plays exactly that song, shuffle or not.
- [x] **The phantom directory.** `Media/Dionne Farris/` existed as far as gvfs
was concerned — it would list the album inside it — and did not exist at
all per `adb`. Every write into it failed `EIO`, and
`mkdir(exist_ok=True)` saw the phantom and did nothing, so it could never
heal while the mount lived. Freshly made folders worked fine, so it was
stale cache, not a broken mount. Only remounting clears it. That folder
was new because Round 52's retag moved the file under a new artist.
- [x] **A refused copy 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 what trav was hearing. The names come back in
the summary and a dialog, so it is never silent.
- [x] **Planning is a worker** (`AndTunesPlanWorker`). It 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. `disk_usage` rides along, being one more MTP call.
- [x] **The tail of the sync says what it's doing.** "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.
- [x] **The wheel scrolls smoothly, and the other way** (`WheelScroll`). 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. Direction
flipped in lists only; volume on the other screens is unchanged.
### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1) ### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1)
trav synced to the Rabbit, the sync reported success, and andTunes answered trav synced to the Rabbit, the sync reported success, and andTunes answered
+186
View File
@@ -0,0 +1,186 @@
"""Round 55: a song the device wouldn't take, and a window that stayed alive.
trav's Rabbit played the wrong song whenever he tapped the first track of his
playlist — shuffle off gave him song 2, shuffle on gave him a random one. The
player was right both times: `library.json` listed a song whose file was not
on the device, and skipping an unplayable file advances one slot.
The file was missing because a gvfs-MTP mount can hold a **phantom directory**
— one it lists but the device doesn't have. Every write into it fails EIO, and
`mkdir(exist_ok=True)` sees the phantom and does nothing, so it never heals.
Two things were wrong with how sync met that: it aborted the whole run, and
(worse, when it didn't) the track still went into the manifest.
What's pinned:
* a copy that raises OSError costs **that song**, not the sync, and the song
is left out of the m3u and `library.json` — the app can only list what it
can play — and comes back in the summary so it isn't silent;
* planning is a **worker**, because it walks every file on the device and over
MTP that froze the window long enough for GNOME to offer to kill LinTunes;
* the phases after the last album (`_write_index`, buttons, pruning) **say so**
— they were minutes of silence with the progress line stuck on the last
album, which reads exactly like a hang.
"""
import json
import pytest
from lintunes.andtunes.sync import (
AndTunesPlanWorker, AndTunesSyncWorker, plan_andtunes_sync,
)
from lintunes.models import Track
from lintunes.models.playlist import Playlist, PlaylistType
@pytest.fixture(autouse=True)
def _private_cache(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
DATA = b"x" * 64
def _track(tid, name, artist="Band", album="Record", *, tmp_path):
path = tmp_path / "local" / f"{tid}.mp3"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(DATA)
return Track(track_id=tid, name=name, artist=artist, album=album,
location=str(path), total_time=60_000, kind="MPEG audio file")
def _playlist(*tids):
return Playlist(name="Mix", persistent_id="AAAA0001",
playlist_type=PlaylistType.REGULAR, track_ids=list(tids))
def _root(tmp_path):
return tmp_path / "device" / "Music" / "andTunes"
def _run(plan):
worker = AndTunesSyncWorker(plan)
out = {"labels": []}
worker.finished.connect(lambda s: out.setdefault("finished", s))
worker.failed.connect(lambda m: out.setdefault("failed", m))
worker.progress.connect(lambda done, total, label: out["labels"].append(label))
worker._run()
return out
def _manifest(root):
return json.loads((root / "library.json").read_text(encoding="utf-8"))
def _wedge(monkeypatch, bad_name: str):
"""Make open(..., 'wb') raise EIO for one destination, like a phantom dir."""
real = open
def fake(file, mode="r", *a, **kw):
if "w" in str(mode) and str(file).endswith(bad_name):
raise OSError(5, "Input/output error", str(file))
return real(file, mode, *a, **kw)
monkeypatch.setattr("builtins.open", fake)
# ---- one song the device refuses ----
def test_a_refused_copy_costs_that_song_not_the_sync(tmp_path, monkeypatch):
tracks = {
1: _track(1, "Wanted", tmp_path=tmp_path),
2: _track(2, "Refused", tmp_path=tmp_path),
3: _track(3, "Also Wanted", tmp_path=tmp_path),
}
plan = plan_andtunes_sync([_playlist(1, 2, 3)], tracks, _root(tmp_path))
_wedge(monkeypatch, "Refused.mp3")
out = _run(plan)
assert "failed" not in out, out.get("failed")
summary = out["finished"]
assert summary["tracks"] == 2
assert [name for name, _why in summary["unwritable"]] == ["Band - Refused"]
assert "Input/output error" in summary["unwritable"][0][1]
def test_a_refused_song_is_left_out_of_the_library_the_app_reads(
tmp_path, monkeypatch):
"""The whole point: the app must never list a song it can't play."""
tracks = {
1: _track(1, "Wanted", tmp_path=tmp_path),
2: _track(2, "Refused", tmp_path=tmp_path),
}
root = _root(tmp_path)
plan = plan_andtunes_sync([_playlist(1, 2)], tracks, root)
_wedge(monkeypatch, "Refused.mp3")
_run(plan)
manifest = _manifest(root)
assert [t["id"] for t in manifest["tracks"]] == [1]
assert manifest["playlists"][0]["tracks"] == [1]
m3u = (root / "Playlists" / "Mix.m3u").read_text(encoding="utf-8")
assert "Refused" not in m3u
assert not (root / "Media/Band/Record/Refused.mp3").exists()
def test_no_refusals_means_no_noise_in_the_summary(tmp_path):
tracks = {1: _track(1, "Fine", tmp_path=tmp_path)}
plan = plan_andtunes_sync([_playlist(1)], tracks, _root(tmp_path))
summary = _run(plan)["finished"]
assert summary["unwritable"] == []
assert summary["tracks"] == 1
# ---- the tail of the sync says what it's doing ----
def test_the_phases_after_the_art_report_progress(tmp_path):
tracks = {1: _track(1, "Fine", tmp_path=tmp_path)}
plan = plan_andtunes_sync([_playlist(1)], tracks, _root(tmp_path))
labels = _run(plan)["labels"]
assert any(l.startswith("Writing playlist") for l in labels), labels
assert "Writing the library" in labels, labels
assert "Tidying up" in labels, labels
# And the index phase is the *last* thing said, not a stale album count.
assert labels[-1] == "Tidying up"
# ---- planning is a worker ----
def test_planning_runs_off_the_gui_thread_and_reports_a_plan(tmp_path):
tracks = {1: _track(1, "Fine", tmp_path=tmp_path)}
worker = AndTunesPlanWorker(
[_playlist(1)], tracks, _root(tmp_path), None, copy_art=False)
got = {}
worker.planned.connect(lambda plan, free: got.update(plan=plan, free=free))
worker.failed.connect(lambda m: got.update(failed=m))
worker._run()
assert "failed" not in got, got.get("failed")
assert [i.track_id for i in got["plan"].items] == [1]
assert got["free"] == 0 # no device, so nothing to measure
def test_a_device_that_goes_away_while_planning_is_reported_not_raised(
tmp_path, monkeypatch):
tracks = {1: _track(1, "Fine", tmp_path=tmp_path)}
monkeypatch.setattr(
"lintunes.andtunes.sync.plan_andtunes_sync",
lambda *a, **kw: (_ for _ in ()).throw(OSError("device went away")))
worker = AndTunesPlanWorker(
[_playlist(1)], tracks, _root(tmp_path), None, copy_art=False)
got = {}
worker.planned.connect(lambda plan, free: got.update(plan=plan))
worker.failed.connect(lambda m: got.update(failed=m))
worker._run()
assert "plan" not in got
assert "device went away" in got["failed"]