Skip to content

Commit c3026e8

Browse files
feat: Add playtime tracking backend (#4108)
* Add support for pre_script_args and post_script_args in Manager and WineExecutor * Add pre_script_args and post_script_args support in WineCommand and WineProgram Enhance the WineCommand and WineProgram classes to accept pre-run and post-run script arguments. Update the UI to include fields for these arguments in the launch options dialog. * Implement placeholder handling in WineExecutor and add unit tests * Enhance BottleConfig with YAML registration and improve placeholder handling in WineExecutor * Refactor BottleConfig to simplify YAML registration and enhance dataclass handling in yaml utility * Update tooltip texts and add translations for pre-run and post-run script arguments in launch options dialog * implement core infra for playtime tracking * add tests * implement signals * Refactor playtime signal tests to use ProcessStartedPayload and ProcessFinishedPayload for improved clarity and structure. * Use Result where needed * add debug logs * Add debug logging for playtime signal events and ensure proper database shutdown on exit * Refactor tests to use mocker instead of monkeypatch for WineExecutor and WinePath, improving test clarity and maintainability. * Add playtime tracking configuration options to gschema.xml, including enabling tracking and setting heartbeat interval. * move integration tests * Update development dependencies: add pytest-mock and freezegun to requirements.dev.txt * reset config.py * chore: change base to main * refactor: remove unused imports in executor.py * refactor: remove logging of executed command in WineCommand --------- Co-authored-by: Mirko Brombin <[email protected]>
1 parent b774a73 commit c3026e8

25 files changed

+1800
-13
lines changed

bottles/backend/globals.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Paths:
4848
latencyflex = f"{base}/latencyflex"
4949
templates = f"{base}/templates"
5050
library = f"{base}/library.yml"
51+
process_metrics = f"{base}/process_metrics.sqlite"
5152

5253
@staticmethod
5354
def is_vkbasalt_available():

bottles/backend/managers/manager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from bottles.backend.managers.importer import ImportManager
4545
from bottles.backend.managers.installer import InstallerManager
4646
from bottles.backend.managers.library import LibraryManager
47+
from bottles.backend.managers.playtime import ProcessSessionTracker
4748
from bottles.backend.managers.repository import RepositoryManager
4849
from bottles.backend.managers.steam import SteamManager
4950
from bottles.backend.managers.template import TemplateManager
@@ -52,6 +53,7 @@
5253
from bottles.backend.models.config import BottleConfig
5354
from bottles.backend.models.result import Result
5455
from bottles.backend.models.samples import Samples
56+
from bottles.backend.models.result import Result
5557
from bottles.backend.state import SignalManager, Signals, Events, EventManager
5658
from bottles.backend.utils import yaml
5759
from bottles.backend.utils.connection import ConnectionUtils
@@ -71,6 +73,11 @@
7173
from bottles.backend.wine.wineboot import WineBoot
7274
from bottles.backend.wine.winepath import WinePath
7375
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+
)
7481

7582
logging = Logger()
7683

@@ -103,6 +110,7 @@ class Manager(metaclass=Singleton):
103110
supported_latencyflex = {}
104111
supported_dependencies = {}
105112
supported_installers = {}
113+
_playtime_signals_connected: bool = False
106114

107115
def __init__(
108116
self,
@@ -154,6 +162,22 @@ def __init__(
154162
self.steam_manager = SteamManager()
155163
times["SteamManager"] = time.time()
156164

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+
157181
if not self.is_cli:
158182
times.update(self.checks(install_latest=False, first_run=True).data)
159183
else:
@@ -288,6 +312,95 @@ def checks(
288312

289313
return rv
290314

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+
291404
def __clear_temp(self, force: bool = False):
292405
"""Clears the temp directory if user setting allows it. Use the force
293406
parameter to force clearing the directory.

bottles/backend/managers/meson.build

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ bottles_sources = [
2424
'origin.py',
2525
'queue.py',
2626
'steamgriddb.py',
27-
'thumbnail.py'
27+
'thumbnail.py',
28+
'playtime.py'
2829
]
2930

3031
install_data(bottles_sources, install_dir: managersdir)

0 commit comments

Comments
 (0)