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.
63 lines
2.5 KiB
Python
63 lines
2.5 KiB
Python
"""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 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.
|
|
|
|
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.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, 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)
|
|
|
|
def _available(self) -> bool:
|
|
return self._iface.isValid()
|
|
|
|
def inhibit(self):
|
|
"""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, _uint(0), self._reason, _uint(self._flags)))
|
|
if reply.isValid():
|
|
self._cookie = int(reply.value())
|
|
|
|
def release(self):
|
|
"""Release a held inhibitor, if any."""
|
|
if self._cookie is None:
|
|
return
|
|
if self._available():
|
|
self._iface.call("Uninhibit", _uint(self._cookie))
|
|
self._cookie = None
|