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.
This commit is contained in:
2026-08-07 17:59:25 -04:00
parent dd3d86aa5c
commit a0a1aea57e
7 changed files with 251 additions and 24 deletions
+28 -11
View File
@@ -1,26 +1,43 @@
"""Keep the machine awake while music is playing.
"""Keep the machine awake while music plays or a device sync runs.
Uses the GNOME SessionManager D-Bus interface (the same session bus PyQt6's
QtDBus already talks to for MPRIS) to register a *suspend* inhibitor. GNOME's
critical-battery action overrides this inhibitor, so a near-dead laptop still
suspends as expected — we only block idle/automatic suspend while playing.
QtDBus already talks to for MPRIS) to register an inhibitor. Playback holds a
*suspend* inhibitor; a device sync additionally holds *logout* (which covers
shutdown/reboot from the session — GNOME's power dialog names the holder and
its reason). GNOME's critical-battery action overrides these, so a near-dead
laptop still suspends as expected.
On non-GNOME desktops the interface won't be available; every method then
no-ops silently rather than failing.
Instances are independent (one D-Bus cookie each), so playback and sync can
hold inhibitors simultaneously without stepping on each other. On non-GNOME
desktops the interface won't be available; every method then no-ops silently
rather than failing.
"""
from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusReply
from PyQt6.QtCore import QMetaType
from PyQt6.QtDBus import (QDBusArgument, QDBusConnection, QDBusInterface,
QDBusReply)
SERVICE = "org.gnome.SessionManager"
PATH = "/org/gnome/SessionManager"
INHIBIT_LOGOUT = 1 # GsmInhibitorFlag: inhibit logout/shutdown/reboot
INHIBIT_SUSPEND = 4 # GsmInhibitorFlag: inhibit suspending the session/computer
APP_ID = "org.lintunes.LinTunes"
REASON = "Playing music"
def _uint(value: int) -> QDBusArgument:
"""Marshal as D-Bus uint32. Inhibit's signature is (susu) — a plain
Python int marshals as *signed* int32 and GNOME rejects the whole call
with a type error (which QDBusReply reported merely as "invalid", so
the inhibitor silently never took effect)."""
return QDBusArgument(value, QMetaType.Type.UInt.value)
class SleepInhibitor:
def __init__(self):
def __init__(self, reason: str = REASON, flags: int = INHIBIT_SUSPEND):
self._cookie: int | None = None
self._reason = reason
self._flags = flags
bus = QDBusConnection.sessionBus()
self._iface = QDBusInterface(SERVICE, PATH, SERVICE, bus)
@@ -28,11 +45,11 @@ class SleepInhibitor:
return self._iface.isValid()
def inhibit(self):
"""Block automatic suspend. No-op if already inhibiting or unavailable."""
"""Take the inhibitor. No-op if already inhibiting or unavailable."""
if self._cookie is not None or not self._available():
return
reply = QDBusReply(self._iface.call(
"Inhibit", APP_ID, 0, REASON, INHIBIT_SUSPEND))
"Inhibit", APP_ID, _uint(0), self._reason, _uint(self._flags)))
if reply.isValid():
self._cookie = int(reply.value())
@@ -41,5 +58,5 @@ class SleepInhibitor:
if self._cookie is None:
return
if self._available():
self._iface.call("Uninhibit", self._cookie)
self._iface.call("Uninhibit", _uint(self._cookie))
self._cookie = None