v0.20.4: the song that wasn't there

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-14 13:25:15 -07:00
co-authored by Claude Opus 5
parent def7635a87
commit efa07b05c6
12 changed files with 457 additions and 36 deletions
+15
View File
@@ -347,6 +347,21 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
device diff, which had been re-copying every such file over MTP every sync.
`plan.stale` still carries the device's own spelling — that is what
`_delete_stale` unlinks by.
**A gvfs-MTP mount can hold a *phantom* directory** — one it lists happily
while the device has no such folder — and every write into it fails `EIO`
forever, because `mkdir(exist_ok=True)` sees the phantom and does nothing.
Only remounting clears it (`gio mount -u mtp://…` then `gio mount`). Two
rules came out of it (Round 55): a copy that raises `OSError` costs **that
song**, not the sync (it lands in the `unwritable` summary list), and the
song is then **left out of the m3u and `library.json`** — a manifest naming
a file that isn't there is worse than a short one, because the app skips to
the next track and the user sees the wrong song play. **Planning is a worker**
too (`AndTunesPlanWorker`): `plan_andtunes_sync` is pure, but it walks every
file on the device, and over MTP that froze the window long enough for GNOME
to offer to kill LinTunes mid-sync. Anything after the last album
(`_write_index`, buttons, pruning) must keep emitting progress — it was
minutes of silence with the line stuck on `Album art 501/501`, and
`_write_index` reads `Art/` in **one listing**, never a stat per album.
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
+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
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
`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
`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see
`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round
@@ -52,6 +52,7 @@ public class ListActivity extends BaseActivity {
private String mode, id;
private TextView titleBar;
private ListView list;
private WheelScroll wheel;
private EditText searchBox;
private final RowAdapter adapter = new RowAdapter();
private final ArrayList<Row> rows = new ArrayList<>();
@@ -84,6 +85,7 @@ public class ListActivity extends BaseActivity {
list.setBackgroundColor(Ui.WHITE);
list.setAdapter(adapter);
list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position)));
wheel = new WheelScroll(list);
column.addView(list, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
@@ -95,6 +97,7 @@ public class ListActivity extends BaseActivity {
@Override protected void onDestroy() {
Library.forget(onLoaded);
wheel.stop();
super.onDestroy();
}
@@ -103,8 +106,9 @@ public class ListActivity extends BaseActivity {
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) {
list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP));
wheel.push(dir * Ui.dp(this, WheelScroll.STEP_DP));
}
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
from pathlib import Path
VERSION_NAME = "0.2.1"
VERSION_CODE = 3
VERSION_NAME = "0.2.2"
VERSION_CODE = 4
PACKAGE = "me.teafry.andtunes"
MIN_SDK = 26
+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 ----
+37
View File
@@ -1,5 +1,42 @@
## 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)
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"]