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.

Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-14 13:25:15 -07:00
parent 48a0d87bd3
commit 6c0e787d49
12 changed files with 457 additions and 36 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""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 os
import shutil
import threading
from dataclasses import dataclass, field
from importlib import resources
@@ -34,8 +35,8 @@ from . import art as art_mod
from . import layout
from . import plays as plays_mod
from .layout import (
BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath,
assert_inside, media_relpath, playlist_relpath,
ART_DIR, BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key,
art_relpath, assert_inside, media_relpath, playlist_relpath,
)
from .manifest import build_manifest
@@ -284,6 +285,57 @@ class _Cancelled(Exception):
"""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):
"""Runs an AndTunesPlan on a daemon thread, reporting through signals.
@@ -343,6 +395,7 @@ class AndTunesSyncWorker(QObject):
self._done = 0
self._plays = 0 # tracks whose play counts came back
self._unconverted = 0 # conversions ffmpeg couldn't do
self._unwritable: list[tuple[str, str]] = [] # (song, why) per refusal
try:
self._check_cancel()
@@ -369,6 +422,7 @@ class AndTunesSyncWorker(QObject):
"plays": self._plays,
"refused": len(plan.refused),
"unconverted": self._unconverted,
"unwritable": list(self._unwritable),
})
except _Cancelled:
self._drop_partial()
@@ -481,19 +535,36 @@ class AndTunesSyncWorker(QObject):
self._emit(label)
continue
dest = plan.root / item.rel
dest.parent.mkdir(parents=True, exist_ok=True)
self._dest = dest
# Manual chunked copy: byte-accurate progress, and no copystat
# (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()
# One song the device won't take is one song, not the sync. A
# gvfs-MTP mount can hold a *phantom* directory — one it lists but
# the device doesn't have — and every write into it fails EIO
# forever, since mkdir(exist_ok=True) sees the phantom and does
# nothing. Aborting here cost trav 2,400 songs for one folder; and
# counting the track present anyway (the older sin) put a song in
# library.json that isn't on the device, which the app then skips
# mid-playlist. So: report it, and leave it out of the manifest.
try:
dest.parent.mkdir(parents=True, exist_ok=True)
self._dest = dest
# Manual chunked copy: byte-accurate progress, and no copystat
# (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._present.add(item.track_id)
@@ -535,7 +606,9 @@ class AndTunesSyncWorker(QObject):
with holes in it.
"""
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)
if tid in self._present]
dest = plan.root / entry.rel
@@ -544,9 +617,16 @@ class AndTunesSyncWorker(QObject):
# 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
# 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
if (plan.root / art_relpath(key)).exists()}
if Path(art_relpath(key)).name in on_device}
entries = [(item.track, item.rel,
art_relpath(k) if (k := album_key(item.track)) in have_art
else None)
@@ -568,6 +648,7 @@ class AndTunesSyncWorker(QObject):
file costs a default image, not the sync.
"""
plan = self._plan
self._emit("Tidying up")
folder = assert_inside(plan.root, plan.root / BUTTONS_DIR)
for src in default_buttons():
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.statusBar().addPermanentWidget(self._sync_cancel)
self._andtunes_worker = None
self._andtunes_planner = None
self._install_worker = None
self._sync_device_name = ""
# Export shares the status-bar progress widgets with device sync —
@@ -465,8 +466,8 @@ class MainWindow(QMainWindow):
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._andtunes_worker, self._export_worker,
self._url_worker):
for worker in (self._andtunes_planner, self._andtunes_worker,
self._export_worker, self._url_worker):
if worker is not None and worker.busy():
return worker
return None
@@ -548,17 +549,26 @@ class MainWindow(QMainWindow):
"Pick them in Connections → Rabbit Sync Settings…")
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)
try:
plan = andtunes_sync.plan_andtunes_sync(
playlists, self._manager.library.tracks, root, device,
copy_art=bool(self._prefs.device_sync.get("copy_art", True)),
data_dir=self._manager.data_dir)
free = shutil.disk_usage(device.root).free
except OSError as e:
QMessageBox.warning(self, "Sync failed",
f"Couldn't read the {device.name}: {e}")
return
self._andtunes_planner = andtunes_sync.AndTunesPlanWorker(
playlists, self._manager.library.tracks, root, device,
copy_art=bool(self._prefs.device_sync.get("copy_art", True)),
data_dir=self._manager.data_dir, parent=self)
self._andtunes_planner.planned.connect(self._on_andtunes_planned)
self._andtunes_planner.failed.connect(self._on_sync_failed)
self._sync_device_name = device.name
self._sync_label.setText(f"Checking the {device.name}")
self._sync_progress.setRange(0, 0) # busy bar: no total to show yet
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():
conversions = plan.conversions
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)"
if summary.get("unconverted"):
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"):
msg += f"; play counts back for {summary['plays']} song(s)"
# The journal landed in the data dir; fold it in now rather than
# waiting for the file watcher.
self._manager.reload_from_disk()
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 ----