diff --git a/CLAUDE.md b/CLAUDE.md index 9552632..1ef4a60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index 3db06ee..5271602 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -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 diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java index d6ac0fb..d09ebd4 100644 --- a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java +++ b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java @@ -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 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() { diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java b/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java new file mode 100644 index 0000000..fcff0ab --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/WheelScroll.java @@ -0,0 +1,68 @@ +package me.teafry.andtunes; + +import android.view.Choreographer; +import android.widget.ListView; + +/** + * Smooth scrolling for the R1's wheel. + * + *

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 + * debt, 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. + * + *

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); + } +} diff --git a/andtunes/build.py b/andtunes/build.py index 06fbff1..808e8fb 100644 --- a/andtunes/build.py +++ b/andtunes/build.py @@ -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 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index ccdd8f9..911530f 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.20.3" +__version__ = "0.20.4" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk index de60aee..5f2e8fa 100644 Binary files a/lintunes/android/andTunes.apk and b/lintunes/android/andTunes.apk differ diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json index ebae942..6cbac99 100644 --- a/lintunes/android/andTunes.json +++ b/lintunes/android/andTunes.json @@ -1 +1 @@ -{"version_name": "0.2.1", "version_code": 3} +{"version_name": "0.2.2", "version_code": 4} diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index 68bccce..321f72c 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -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 diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 7e647db..2a2f5d1 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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 ---- diff --git a/tasks-done.md b/tasks-done.md index 3ece031..930cb52 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -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 diff --git a/tests/test_round55.py b/tests/test_round55.py new file mode 100644 index 0000000..db03ab5 --- /dev/null +++ b/tests/test_round55.py @@ -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"]