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:
@@ -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
@@ -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
@@ -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,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 @@
|
|||||||
{"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 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
@@ -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 ----
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user