v0.2.2: device-sync guards — quit warning, sleep/shutdown inhibition
Quitting mid-transfer now asks (Keep Syncing / Quit Anyway) from closeEvent, which every quit path hits — including the self-update restart, which previously bypassed closeEvent via a bare quit() and now routes through close(). A running sync holds its own GNOME inhibitor (suspend + logout, "Syncing a playlist to a device") so the machine won't sleep or shutdown/restart under a copy. Along the way: the Inhibit D-Bus call marshaled Python ints as signed against GNOME's (susu) signature, so every call was rejected and the playback sleep inhibitor had silently never worked — _uint fixes both holders (verified live: cookies taken, IsInhibited flips, releases clean). Also investigated trav's mid-copy Syncthing question: the transfer works from a click-time snapshot and never reads live library state, so remote changes can't corrupt it — no state freeze needed. Hardened the one real gap: a source file relocated under the queue (remote metadata edit moving files) is now skipped, dropped from the m3u, and reported, instead of aborting the whole sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,15 +177,16 @@ class TestWorker:
|
||||
assert seen[-1][0] == seen[-1][1] == 2 * device_sync.CHUNK // 1024
|
||||
assert "1/1" in seen[-1][2]
|
||||
|
||||
def test_source_vanishing_reports_failure(self, qapp, tmp_path):
|
||||
def test_device_side_error_reports_failure(self, qapp, tmp_path):
|
||||
# A file squatting on the dest-dir path stands in for a device-side
|
||||
# write error (unplug mid-sync). Vanished *sources* are tolerated
|
||||
# (round 27); device errors still abort.
|
||||
a = _audio(tmp_path, "a.mp3")
|
||||
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
|
||||
a.unlink() # unplugged/deleted between plan and run
|
||||
(tmp_path / "Music").mkdir()
|
||||
(tmp_path / "Music" / "Mix").write_bytes(b"not a dir")
|
||||
result = self._synced(qapp, plan)
|
||||
assert "Sync failed" in result["error"]
|
||||
# No half-copied file left behind at full-looking size.
|
||||
leftover = plan.dest_dir / "X - A.mp3"
|
||||
assert not leftover.exists() or leftover.stat().st_size != plan.copies[0].size
|
||||
|
||||
def test_empty_playlist_clears_folder(self, qapp, tmp_path):
|
||||
dest = tmp_path / "Music" / "Mix"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Round 27: device-sync guards.
|
||||
|
||||
A running transfer now (a) holds a GNOME suspend+logout inhibitor so the
|
||||
machine won't sleep or restart under it, (b) is protected by a quit-warning
|
||||
in MainWindow.closeEvent (not testable headless — no MainWindow tests exist),
|
||||
and (c) tolerates a source file vanishing mid-queue: Syncthing applying a
|
||||
remote metadata edit can relocate a music file between planning and copying,
|
||||
so the worker skips it, drops it from the m3u, and reports it instead of
|
||||
aborting the whole transfer. Device-side errors still abort.
|
||||
"""
|
||||
|
||||
from lintunes.device_sync import DeviceSyncWorker, plan_sync
|
||||
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
|
||||
from lintunes.models import Track
|
||||
|
||||
|
||||
def _track(tid, name, artist, path):
|
||||
return Track(track_id=tid, name=name, artist=artist,
|
||||
location=str(path), total_time=180_000)
|
||||
|
||||
|
||||
def _audio(tmp_path, filename, data=b"x" * 100):
|
||||
path = tmp_path / "local" / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
class _FakeIface:
|
||||
"""Stands in for the QDBusInterface: records calls, hands out cookies."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self._next_cookie = 41
|
||||
|
||||
def isValid(self):
|
||||
return True
|
||||
|
||||
def call(self, method, *args):
|
||||
from PyQt6.QtDBus import QDBusMessage
|
||||
self.calls.append((method, *args))
|
||||
stub = QDBusMessage.createMethodCall("a.b", "/", "a.b", method)
|
||||
if method == "Inhibit":
|
||||
self._next_cookie += 1
|
||||
return stub.createReply([self._next_cookie])
|
||||
return stub.createReply([])
|
||||
|
||||
|
||||
class TestSleepInhibitor:
|
||||
def _patched(self, monkeypatch, **kwargs):
|
||||
# _uint wraps ints in write-only QDBusArguments; identity here so the
|
||||
# fake can record and assert on the raw values.
|
||||
from lintunes import inhibit as inhibit_module
|
||||
monkeypatch.setattr(inhibit_module, "_uint", lambda v: v)
|
||||
inhibitor = SleepInhibitor(**kwargs)
|
||||
inhibitor._iface = _FakeIface()
|
||||
return inhibitor
|
||||
|
||||
def test_custom_reason_and_flags_reach_dbus(self, qapp, monkeypatch):
|
||||
inhibitor = self._patched(
|
||||
monkeypatch, reason="Syncing a playlist to a device",
|
||||
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
|
||||
inhibitor.inhibit()
|
||||
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
|
||||
assert method == "Inhibit"
|
||||
assert reason == "Syncing a playlist to a device"
|
||||
assert flags == 5 # logout (1) + suspend (4)
|
||||
|
||||
def test_defaults_unchanged_for_playback(self, qapp, monkeypatch):
|
||||
inhibitor = self._patched(monkeypatch)
|
||||
inhibitor.inhibit()
|
||||
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
|
||||
assert reason == "Playing music"
|
||||
assert flags == INHIBIT_SUSPEND == 4
|
||||
|
||||
def test_release_uninhibits_with_cookie(self, qapp, monkeypatch):
|
||||
inhibitor = self._patched(monkeypatch)
|
||||
inhibitor.inhibit()
|
||||
cookie = inhibitor._cookie
|
||||
inhibitor.release()
|
||||
assert ("Uninhibit", cookie) in inhibitor._iface.calls
|
||||
assert inhibitor._cookie is None
|
||||
|
||||
def test_second_inhibit_while_held_is_noop(self, qapp, monkeypatch):
|
||||
inhibitor = self._patched(monkeypatch)
|
||||
inhibitor.inhibit()
|
||||
inhibitor.inhibit()
|
||||
assert len([c for c in inhibitor._iface.calls
|
||||
if c[0] == "Inhibit"]) == 1
|
||||
|
||||
def test_release_without_hold_is_noop(self, qapp, monkeypatch):
|
||||
inhibitor = self._patched(monkeypatch)
|
||||
inhibitor.release()
|
||||
assert inhibitor._iface.calls == []
|
||||
|
||||
|
||||
class TestVanishedSource:
|
||||
def _run(self, plan):
|
||||
worker = DeviceSyncWorker(plan)
|
||||
results = {}
|
||||
worker.finished.connect(lambda s: results.update(s))
|
||||
worker.failed.connect(lambda m: results.update(error=m))
|
||||
worker._run()
|
||||
return results
|
||||
|
||||
def test_vanished_source_is_skipped_not_fatal(self, qapp, tmp_path):
|
||||
a = _audio(tmp_path, "a.mp3", b"a" * 50)
|
||||
b = _audio(tmp_path, "b.mp3", b"b" * 70)
|
||||
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
|
||||
plan = plan_sync("Mix", tracks, tmp_path / "Music")
|
||||
a.unlink() # Syncthing relocated it between plan and copy
|
||||
|
||||
summary = self._run(plan)
|
||||
|
||||
assert "error" not in summary
|
||||
assert summary["vanished"] == 1
|
||||
assert summary["copied"] == 1
|
||||
assert not (plan.dest_dir / "X - A.mp3").exists()
|
||||
assert (plan.dest_dir / "Y - B.mp3").read_bytes() == b"b" * 70
|
||||
|
||||
def test_vanished_track_left_out_of_m3u(self, qapp, tmp_path):
|
||||
a = _audio(tmp_path, "a.mp3")
|
||||
b = _audio(tmp_path, "b.mp3")
|
||||
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
|
||||
plan = plan_sync("Mix", tracks, tmp_path / "Music")
|
||||
a.unlink()
|
||||
|
||||
self._run(plan)
|
||||
|
||||
m3u = (plan.dest_dir / "Mix.m3u").read_text(encoding="utf-8")
|
||||
assert "X - A.mp3" not in m3u
|
||||
assert "Y - B.mp3" in m3u
|
||||
|
||||
def test_all_sources_vanished_still_finishes(self, qapp, tmp_path):
|
||||
a = _audio(tmp_path, "a.mp3")
|
||||
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
|
||||
a.unlink()
|
||||
summary = self._run(plan)
|
||||
assert summary["vanished"] == 1 and summary["copied"] == 0
|
||||
assert (plan.dest_dir / "Mix.m3u").read_text() == "#EXTM3U\n"
|
||||
Reference in New Issue
Block a user