235 lines
8.6 KiB
Python
235 lines
8.6 KiB
Python
"""Anti-sleep / anti-hibernate subsystem (v6-06).
|
|
|
|
Keeps the system awake during long transcodes using two complementary
|
|
approaches:
|
|
|
|
1. **systemd-inhibit** (preferred, available on all systemd Linux distros):
|
|
Runs a "fork bomb" — a no-op child process held open for the duration
|
|
of the transcode. systemd sees the inhibit handle and will NOT suspend
|
|
or hibernate the system while it's active. This is the cleanest
|
|
approach: no mouse movement, no screen-lock interference, no user
|
|
-visible side effects.
|
|
|
|
2. **Periodic mouse nudge** (fallback / belt-and-suspenders):
|
|
If ``xdotool`` is available, moves the mouse 1 pixel every 60 seconds
|
|
(jitter, not constant movement — the user can still click STOP or
|
|
close the window). This catches DEs that ignore systemd-inhibit
|
|
(rare) and prevents screen-blanking timeouts. The movement is
|
|
minimal: +1px right, then -1px left on the next tick, so the cursor
|
|
ends up where it started.
|
|
|
|
The user sees a bright-red status banner in the UI while keep-awake is
|
|
active:
|
|
|
|
⚠ KEEP-AWAKE ACTIVE — system will not sleep | ETA: ~45 min | [STOP]
|
|
|
|
The banner is updated every 5 seconds with a fresh ETA. The user can
|
|
click STOP or the window close X at any time — both tear down the
|
|
keep-awake handles cleanly.
|
|
|
|
Design decisions:
|
|
- systemd-inhibit is the PRIMARY mechanism. Mouse nudging is secondary.
|
|
- Mouse nudging is OFF by default (opt-in via constructor flag) because
|
|
it's visually intrusive. systemd-inhibit is always-on when available.
|
|
- The inhibit handle is held in a subprocess (not the main process) so
|
|
it survives even if the GUI crashes — systemd cleans it up when the
|
|
subprocess exits.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
|
|
class KeepAwake:
|
|
"""Keep the system awake during a transcode.
|
|
|
|
Usage::
|
|
|
|
ka = KeepAwake(log_fn=worker.log_msg.emit)
|
|
ka.start()
|
|
try:
|
|
# ... long encode ...
|
|
while encoding:
|
|
ka.update_eta(remaining_seconds)
|
|
time.sleep(5)
|
|
finally:
|
|
ka.stop() # releases inhibit + stops mouse nudging
|
|
|
|
The ETA is displayed in the UI banner via ``update_eta()``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
log_fn=None,
|
|
enable_mouse_nudge: bool = False,
|
|
nudge_interval: int = 60,
|
|
):
|
|
self._log_fn = log_fn or (lambda msg: None)
|
|
self._enable_mouse_nudge = enable_mouse_nudge and bool(shutil.which("xdotool"))
|
|
self._nudge_interval = nudge_interval
|
|
self._inhibit_proc: subprocess.Popen | None = None
|
|
self._nudge_count = 0
|
|
self._last_nudge = 0.0
|
|
self._start_time = 0.0
|
|
self._eta_seconds: float | None = None
|
|
self._active = False
|
|
|
|
def start(self) -> None:
|
|
"""Acquire systemd-inhibit handle. Safe to call multiple times."""
|
|
if self._active:
|
|
return
|
|
self._active = True
|
|
self._start_time = time.monotonic()
|
|
self._acquire_inhibit()
|
|
if self._enable_mouse_nudge:
|
|
self._log_fn("KEEP-AWAKE: mouse nudging enabled (xdotool, every "
|
|
f"{self._nudge_interval}s)")
|
|
else:
|
|
self._log_fn("KEEP-AWAKE: mouse nudging disabled (xdotool not found "
|
|
"or not requested)")
|
|
|
|
def stop(self) -> None:
|
|
"""Release the inhibit handle and stop nudging."""
|
|
if not self._active:
|
|
return
|
|
self._active = False
|
|
self._release_inhibit()
|
|
if self._nudge_count > 0:
|
|
self._log_fn(f"KEEP-AWAKE: stopped (mouse nudged {self._nudge_count} times)")
|
|
|
|
def update_eta(self, remaining_seconds: float | None) -> None:
|
|
"""Update the ETA shown in the banner. None = unknown."""
|
|
self._eta_seconds = remaining_seconds
|
|
|
|
def tick(self) -> str | None:
|
|
"""Called periodically (e.g. every 5s) from the UI thread.
|
|
|
|
Performs mouse nudge if interval has elapsed.
|
|
Returns the current banner text, or None if keep-awake is not active.
|
|
"""
|
|
if not self._active:
|
|
return None
|
|
now = time.monotonic()
|
|
if self._enable_mouse_nudge and (now - self._last_nudge) >= self._nudge_interval:
|
|
self._nudge_mouse()
|
|
self._last_nudge = now
|
|
return self.banner_text()
|
|
|
|
def banner_text(self) -> str:
|
|
"""Return the bright-red banner text for the UI."""
|
|
eta_str = self._format_eta(self._eta_seconds)
|
|
elapsed = time.monotonic() - self._start_time
|
|
elapsed_str = self._format_eta(elapsed)
|
|
nudge_str = f" | mouse: {self._nudge_count}" if self._nudge_count > 0 else ""
|
|
return (
|
|
f"KEEP-AWAKE ACTIVE — system will not sleep | "
|
|
f"elapsed: {elapsed_str} | ETA: {eta_str}{nudge_str}"
|
|
)
|
|
|
|
def _format_eta(self, seconds: float | None) -> str:
|
|
if seconds is None:
|
|
return "unknown"
|
|
if seconds < 0:
|
|
return "almost done"
|
|
hours = int(seconds // 3600)
|
|
mins = int((seconds % 3600) // 60)
|
|
secs = int(seconds % 60)
|
|
if hours > 0:
|
|
return f"~{hours}h{mins:02d}m"
|
|
if mins > 0:
|
|
return f"~{mins}m{secs:02d}s"
|
|
return f"~{secs}s"
|
|
|
|
def _acquire_inhibit(self) -> None:
|
|
"""Fork a systemd-inhibit subprocess that holds the sleep/hibernate
|
|
inhibit handle for the duration of the transcode.
|
|
|
|
systemd-inhibit takes a command to run while inhibiting. We pass
|
|
``sleep infinity`` (the GNU coreutils builtin) as the held command —
|
|
it does nothing, runs forever, and the inhibit handle stays active
|
|
until we kill the subprocess.
|
|
"""
|
|
inhibit_bin = shutil.which("systemd-inhibit")
|
|
if not inhibit_bin:
|
|
self._log_fn("KEEP-AWAKE: systemd-inhibit not found — "
|
|
"system may sleep during transcode")
|
|
return
|
|
try:
|
|
# --what=handle-lid-switch:sleep — inhibit both lid-close and
|
|
# automatic sleep/hibernate
|
|
# --who=OpenTranscode — shown in `systemd-inhibit --list`
|
|
# --why="Batch video transcode in progress" — shown in `systemd-inhibit --list`
|
|
# --mode=block — block the action entirely (not just delay)
|
|
self._inhibit_proc = subprocess.Popen(
|
|
[
|
|
inhibit_bin,
|
|
"--what=sleep:idle",
|
|
"--who=OpenTranscode",
|
|
"--why=Batch video transcode in progress",
|
|
"--mode=block",
|
|
"sleep", "infinity",
|
|
],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
# Don't put the child in a new session — we want it to die
|
|
# when the parent dies (implicit via Popen + stop()).
|
|
)
|
|
self._log_fn("KEEP-AWAKE: systemd-inhibit active (sleep/idle blocked)")
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
self._log_fn(f"KEEP-AWAKE: failed to acquire systemd-inhibit: {e}")
|
|
self._inhibit_proc = None
|
|
|
|
def _release_inhibit(self) -> None:
|
|
"""Kill the systemd-inhibit subprocess to release the handle."""
|
|
if self._inhibit_proc is None:
|
|
return
|
|
try:
|
|
self._inhibit_proc.terminate()
|
|
self._inhibit_proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
self._inhibit_proc.kill()
|
|
self._inhibit_proc.wait(timeout=1)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
finally:
|
|
self._inhibit_proc = None
|
|
self._log_fn("KEEP-AWAKE: systemd-inhibit released")
|
|
|
|
def _nudge_mouse(self) -> None:
|
|
"""Move the mouse 1 pixel to prevent screen-blank.
|
|
|
|
Uses xdotool. Alternates +1px right / -1px left so the cursor
|
|
ends up where it started after every pair of nudges.
|
|
"""
|
|
xdotool = shutil.which("xdotool")
|
|
if not xdotool:
|
|
return
|
|
delta = 1 if (self._nudge_count % 2 == 0) else -1
|
|
try:
|
|
subprocess.run(
|
|
[xdotool, "mousemove_relative", "--", str(delta), "0"],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
self._nudge_count += 1
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass # best-effort — don't crash the transcode over a nudge
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
return self._active
|
|
|
|
@property
|
|
def has_inhibit(self) -> bool:
|
|
return self._inhibit_proc is not None
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.stop()
|