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:
@@ -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 @@
|
||||
{"version_name": "0.2.1", "version_code": 3}
|
||||
{"version_name": "0.2.2", "version_code": 4}
|
||||
|
||||
+99
-18
@@ -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
@@ -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 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user