|
44 | 44 | from bottles.backend.managers.importer import ImportManager |
45 | 45 | from bottles.backend.managers.installer import InstallerManager |
46 | 46 | from bottles.backend.managers.library import LibraryManager |
| 47 | +from bottles.backend.managers.playtime import ProcessSessionTracker |
47 | 48 | from bottles.backend.managers.repository import RepositoryManager |
48 | 49 | from bottles.backend.managers.steam import SteamManager |
49 | 50 | from bottles.backend.managers.template import TemplateManager |
|
52 | 53 | from bottles.backend.models.config import BottleConfig |
53 | 54 | from bottles.backend.models.result import Result |
54 | 55 | from bottles.backend.models.samples import Samples |
| 56 | +from bottles.backend.models.result import Result |
55 | 57 | from bottles.backend.state import SignalManager, Signals, Events, EventManager |
56 | 58 | from bottles.backend.utils import yaml |
57 | 59 | from bottles.backend.utils.connection import ConnectionUtils |
|
71 | 73 | from bottles.backend.wine.wineboot import WineBoot |
72 | 74 | from bottles.backend.wine.winepath import WinePath |
73 | 75 | from bottles.backend.wine.wineserver import WineServer |
| 76 | +from bottles.backend.state import SignalManager, Signals |
| 77 | +from bottles.backend.models.process import ( |
| 78 | + ProcessStartedPayload, |
| 79 | + ProcessFinishedPayload, |
| 80 | +) |
74 | 81 |
|
75 | 82 | logging = Logger() |
76 | 83 |
|
@@ -103,6 +110,7 @@ class Manager(metaclass=Singleton): |
103 | 110 | supported_latencyflex = {} |
104 | 111 | supported_dependencies = {} |
105 | 112 | supported_installers = {} |
| 113 | + _playtime_signals_connected: bool = False |
106 | 114 |
|
107 | 115 | def __init__( |
108 | 116 | self, |
@@ -154,6 +162,22 @@ def __init__( |
154 | 162 | self.steam_manager = SteamManager() |
155 | 163 | times["SteamManager"] = time.time() |
156 | 164 |
|
| 165 | + # Initialize playtime tracker |
| 166 | + playtime_enabled = self.settings.get_boolean("playtime-enabled") |
| 167 | + playtime_interval = self.settings.get_int("playtime-heartbeat-interval") |
| 168 | + self.playtime_tracker = ProcessSessionTracker( |
| 169 | + enabled=playtime_enabled, |
| 170 | + heartbeat_interval=playtime_interval if playtime_interval > 0 else 60, |
| 171 | + ) |
| 172 | + self.playtime_tracker.recover_open_sessions() |
| 173 | + times["PlaytimeTracker"] = time.time() |
| 174 | + |
| 175 | + # Subscribe to playtime signals (connect once per process) |
| 176 | + if not Manager._playtime_signals_connected: |
| 177 | + SignalManager.connect(Signals.ProgramStarted, self._on_program_started) |
| 178 | + SignalManager.connect(Signals.ProgramFinished, self._on_program_finished) |
| 179 | + Manager._playtime_signals_connected = True |
| 180 | + |
157 | 181 | if not self.is_cli: |
158 | 182 | times.update(self.checks(install_latest=False, first_run=True).data) |
159 | 183 | else: |
@@ -288,6 +312,95 @@ def checks( |
288 | 312 |
|
289 | 313 | return rv |
290 | 314 |
|
| 315 | + def __del__(self): |
| 316 | + # best-effort shutdown of playtime tracker |
| 317 | + try: |
| 318 | + if hasattr(self, "playtime_tracker") and self.playtime_tracker: |
| 319 | + self.playtime_tracker.shutdown() |
| 320 | + except Exception: |
| 321 | + pass |
| 322 | + |
| 323 | + # Playtime signal handlers |
| 324 | + _launch_to_session: Dict[str, int] = {} |
| 325 | + |
| 326 | + # Public Playtime API (wrap tracker with Result) |
| 327 | + def playtime_start( |
| 328 | + self, |
| 329 | + *, |
| 330 | + bottle_id: str, |
| 331 | + bottle_name: str, |
| 332 | + bottle_path: str, |
| 333 | + program_name: str, |
| 334 | + program_path: str, |
| 335 | + ) -> Result[int]: |
| 336 | + try: |
| 337 | + sid = self.playtime_tracker.start_session( |
| 338 | + bottle_id=bottle_id, |
| 339 | + bottle_name=bottle_name, |
| 340 | + bottle_path=bottle_path, |
| 341 | + program_name=program_name, |
| 342 | + program_path=program_path, |
| 343 | + ) |
| 344 | + return Result(True, data=sid) |
| 345 | + except Exception as e: |
| 346 | + logging.exception(e) |
| 347 | + return Result(False, message=str(e)) |
| 348 | + |
| 349 | + def playtime_finish( |
| 350 | + self, |
| 351 | + session_id: int, |
| 352 | + *, |
| 353 | + status: str = "success", |
| 354 | + ended_at: Optional[int] = None, |
| 355 | + ) -> Result[None]: |
| 356 | + try: |
| 357 | + if status == "success": |
| 358 | + self.playtime_tracker.mark_exit(session_id, status="success", ended_at=ended_at) |
| 359 | + else: |
| 360 | + self.playtime_tracker.mark_failure(session_id, status=status) |
| 361 | + return Result(True) |
| 362 | + except Exception as e: |
| 363 | + logging.exception(e) |
| 364 | + return Result(False, message=str(e)) |
| 365 | + |
| 366 | + def _on_program_started(self, data: Optional[Result] = None) -> None: |
| 367 | + try: |
| 368 | + if not data or not data.data: |
| 369 | + return |
| 370 | + payload: ProcessStartedPayload = data.data # type: ignore |
| 371 | + logging.debug( |
| 372 | + f"Playtime signal: started launch_id={payload.launch_id} bottle={payload.bottle_name} program={payload.program_name}" |
| 373 | + ) |
| 374 | + res = self.playtime_start( |
| 375 | + bottle_id=payload.bottle_id, |
| 376 | + bottle_name=payload.bottle_name, |
| 377 | + bottle_path=payload.bottle_path, |
| 378 | + program_name=payload.program_name, |
| 379 | + program_path=payload.program_path, |
| 380 | + ) |
| 381 | + if not res.ok: |
| 382 | + return |
| 383 | + sid = int(res.data or -1) |
| 384 | + self._launch_to_session[payload.launch_id] = sid |
| 385 | + except Exception: |
| 386 | + pass |
| 387 | + |
| 388 | + def _on_program_finished(self, data: Optional[Result] = None) -> None: |
| 389 | + try: |
| 390 | + if not data or not data.data: |
| 391 | + return |
| 392 | + payload: ProcessFinishedPayload = data.data # type: ignore |
| 393 | + sid = self._launch_to_session.pop(payload.launch_id, -1) |
| 394 | + if sid and sid > 0: |
| 395 | + status = payload.status |
| 396 | + ended_at = int(payload.ended_at or time.time()) |
| 397 | + logging.debug( |
| 398 | + f"Playtime signal: finished launch_id={payload.launch_id} status={status} sid={sid}" |
| 399 | + ) |
| 400 | + self.playtime_finish(sid, status=status, ended_at=ended_at) |
| 401 | + except Exception: |
| 402 | + pass |
| 403 | + |
291 | 404 | def __clear_temp(self, force: bool = False): |
292 | 405 | """Clears the temp directory if user setting allows it. Use the force |
293 | 406 | parameter to force clearing the directory. |
|
0 commit comments