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.
141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
"""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"
|