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>
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
|