Implementation of Discord Rich Presence, along with some typo/flake8 fixes

This commit is contained in:
Jayme Howard 2019-05-10 15:30:11 -05:00 committed by Mathieu Comandon
parent 2c41d9a86e
commit 383db48ccc
8 changed files with 190 additions and 8 deletions

View file

@ -20,12 +20,12 @@ class LutrisConfig:
Description
===========
Lutris' configuration uses a cascading mecanism where
Lutris' configuration uses a cascading mechanism where
each higher, more specific level overrides the lower ones
The levels are (highest to lowest): `game`, `runner` and `system`.
Each level has its own set of options (config section), available to and
overriden by upper levels:
overridden by upper levels:
```
level | Config sections
-------|----------------------

View file

@ -18,7 +18,11 @@ from lutris.config import LutrisConfig
from lutris.command import MonitoredCommand
from lutris.gui import dialogs
from lutris.util.timer import Timer
from lutris.settings import DEFAULT_DISCORD_CLIENT_ID
from pypresence import Presence
from pypresence.exceptions import PyPresenceException
import asyncio
HEARTBEAT_DELAY = 2000
@ -63,6 +67,11 @@ class Game(GObject.Object):
self.steamid = game_data.get("steamid") or ""
self.has_custom_banner = bool(game_data.get("has_custom_banner"))
self.has_custom_icon = bool(game_data.get("has_custom_icon"))
self.discord_last_rpc = 0
self.discord_rpc_interval = 60
self.discord_presence_connected = False
self.discord_rpc_client = None
self.discord_client_id = None
try:
self.playtime = float(game_data.get("playtime") or 0.0)
except ValueError:
@ -85,6 +94,7 @@ class Game(GObject.Object):
self.original_outputs = None
self._log_buffer = None
self.timer = Timer()
self.discord_rpc_client = Presence(self.discord_client_id)
@property
def log_buffer(self):
@ -159,6 +169,11 @@ class Game(GObject.Object):
runner_slug=self.runner_name, game_config_id=self.game_config_id
)
self.runner = self._get_runner()
self.discord_client_id = self.config.game_config.get("discord_client_id") or DEFAULT_DISCORD_CLIENT_ID
self.discord_custom_game_name = self.config.game_config.get("discord_custom_game_name") or ""
self.discord_show_runner = self.config.game_config.get("discord_show_runner", True)
self.discord_custom_runner_name = self.config.game_config.get("discord_custom_runner_name") or ""
self.discord_rpc_enabled = self.config.game_config.get("discord_rpc_enabled", True)
def set_desktop_compositing(self, enable):
"""Enables or disables compositing"""
@ -585,6 +600,9 @@ class Game(GObject.Object):
logger.debug("Game thread stopped")
self.on_game_quit()
return False
if int(time.time()) - self.discord_rpc_interval > self.discord_last_rpc:
self.discord_last_rpc = int(time.time())
self.update_discord_rich_presence()
return True
def stop(self):
@ -623,6 +641,7 @@ class Game(GObject.Object):
cwd=self.directory,
)
postexit_thread.start()
self.clear_discord_rich_presence()
quit_time = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime())
logger.debug("%s stopped at %s", self.name, quit_time)
@ -680,3 +699,97 @@ class Game(GObject.Object):
appmanifest.steamid,
)
self.game_thread.ready_state = False
def ensure_discord_connected(self):
"""Make sure we are actually connected before trying to send requests"""
logger.debug("Ensuring connected.")
if self.discord_presence_connected:
logger.debug("Already connected!")
else:
logger.debug("Creating Presence object.")
self.discord_rpc_client = Presence(self.discord_client_id)
try:
logger.debug("Attempting to connect.")
self.discord_rpc_client.connect()
self.discord_presence_connected = True
except Exception as e:
logger.error(f"Unable to reach Discord. Skipping update: {e}")
self.ensure_discord_disconnected()
return self.discord_presence_connected
def ensure_discord_disconnected(self):
"""Ensure we are definitely disconnected and fix broken event loop from pypresence"""
logger.debug("Ensuring disconnected.")
if self.discord_rpc_client is not None:
try:
self.discord_rpc_client.close()
except Exception as e:
logger.error(f"Unable to close Discord RPC connection: {e}")
if self.discord_rpc_client.sock_writer is not None:
try:
logger.debug("Forcefully closing sock writer.")
self.discord_rpc_client.sock_writer.close()
except Exception:
logger.debug("Sock writer could not be closed.")
try:
logger.debug("Forcefully closing event loop.")
self.discord_rpc_client.loop.close()
except Exception:
logger.debug("Could not close event loop.")
try:
logger.debug("Forcefully replacing event loop.")
self.discord_rpc_client.loop = None
asyncio.set_event_loop(asyncio.new_event_loop())
except Exception as e:
logger.debug(f"Could not replace event loop: {e}")
try:
logger.debug("Forcefully deleting RPC client.")
del self.discord_rpc_client
except Exception:
pass
self.discord_rpc_client = None
self.discord_presence_connected = False
def update_discord_rich_presence(self):
"""Dispatch a request to Discord to update presence"""
if self.discord_rpc_enabled:
logger.debug("RPC is enabled")
connected = self.ensure_discord_connected()
if not connected:
return
try:
if self.discord_custom_game_name != "":
logger.debug(f"Got custom game name: {self.discord_custom_game_name}")
game_name = self.discord_custom_game_name
else:
logger.debug("Using default name")
game_name = self.name
if self.discord_show_runner:
if self.discord_custom_runner_name != "":
logger.debug(f"Got custom runner name: {self.discord_custom_runner_name}")
runner_name = self.discord_custom_runner_name
else:
logger.debug("Using default runner name")
runner_name = self.runner_name
if runner_name != "":
state_text = f"via {runner_name}"
else:
state_text = " "
logger.info(f"Attempting to update Discord status: {game_name}, {state_text}")
self.discord_rpc_client.update(details=f"Playing {game_name}", state=state_text)
except PyPresenceException as e:
logger.error(f"Unable to update Discord: {e}")
else:
logger.debug("RPC disabled")
def clear_discord_rich_presence(self):
"""Dispatch a request to Discord to clear presence"""
if self.discord_rpc_enabled:
connected = self.ensure_discord_connected()
if connected:
try:
logger.info('Attempting to clear Discord status.')
self.discord_rpc_client.clear()
except PyPresenceException as e:
logger.error(f"Unable to clear Discord: {e}")
self.ensure_discord_disconnected()

View file

@ -25,10 +25,6 @@ from gettext import gettext as _
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GnomeDesktop", "3.0")
from gi.repository import Gio, GLib, Gtk
from lutris import pga
from lutris.game import Game
@ -51,6 +47,10 @@ from lutris.util.wine.dxvk import init_dxvk_versions
from .lutriswindow import LutrisWindow
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GnomeDesktop", "3.0")
class Application(Gtk.Application):
def __init__(self):
super().__init__(

View file

@ -41,7 +41,7 @@ LIBRETRO_CORES = [
("MAME (Arcade)", "mame", "Arcade"),
("Mednafen GBA (Game Boy Advance)", "mednafen_gba", "Nintendo Game Boy Advance"),
("Mednafen NGP (SNK Neo Geo Pocket)", "mednafen_ngp", "SNK Neo Geo Pocket"),
("Mednafen PCE (TurboGrafx-16)", "mednafen_pce", "NEC PC Engine (TurboGrafx-16)"),
("Mednafen PCE (TurboGrafx-16)", "mednafen_pce", "NEC PC Engine (TurboGrafx-16)"),
("Mednafen PCE FAST (TurboGrafx-16)", "mednafen_pce_fast", "NEC PC Engine (TurboGrafx-16)"),
("Mednafen PCFX (NEC PC-FX)", "mednafen_pcfx", "NEC PC-FX"),
("Mednafen Saturn (Sega Saturn)", "mednafen_saturn", "Sega Saturn"),

View file

@ -26,6 +26,40 @@ class Runner:
context_menu_entries = []
depends_on = None
runner_executable = None
common_game_options = [
{
"option": "discord_rpc_enabled",
"type": "bool",
"label": "Discord Rich Presence",
"default": True,
"help": "Enable notification to Discord of this game being played",
},
{
"option": "discord_show_runner",
"type": "bool",
"label": "Discord Show Runner",
"default": True,
"help": "Embed the runner name in the Discord notification",
},
{
"option": "discord_custom_game_name",
"type": "string",
"label": "Discord Custom Game Name",
"help": "Custom name to override with and send to Discord",
},
{
"option": "discord_custom_runner_name",
"type": "string",
"label": "Discord Custom Runner Name",
"help": "Custom runner name to override with and send to Discord",
},
{
"option": "discord_client_id",
"type": "string",
"label": "Discord Client ID",
"help": "Custom Discord Client ID for sending notifications",
},
]
def __init__(self, config=None):
"""Initialize runner."""
@ -36,6 +70,7 @@ class Runner:
self.game_data = pga.get_game_by_field(
self.config.game_config_id, "configpath"
)
self.inject_common_game_options()
def __lt__(self, other):
return self.name < other.name
@ -106,6 +141,31 @@ class Runner:
"""Return the working directory to use when running the game."""
return self.game_path or os.path.expanduser("~/")
@property
def discord_rpc_enabled(self):
if self.game_data.get("discord_rpc_enabled"):
return self.game_data.get("discord_rpc_enabled")
@property
def discord_show_runner(self):
if self.game_data.get("discord_show_runner"):
return self.game_data.get("discord_show_runner")
@property
def discord_custom_game_name(self):
if self.game_data.get("discord_custom_game_name"):
return self.game_data.get("discord_custom_game_name")
@property
def discord_custom_runner_name(self):
if self.game_data.get("discord_custom_runner_name"):
return self.game_data.get("discord_custom_runner_name")
@property
def discord_client_id(self):
if self.game_data.get("discord_client_id"):
return self.game_data.get("discord_client_id")
def get_platform(self):
return self.platforms[0]
@ -365,3 +425,9 @@ class Runner:
runner_path = os.path.join(settings.RUNNER_DIR, self.name)
if os.path.isdir(runner_path):
system.remove_folder(runner_path)
def inject_common_game_options(self):
"""Dynamically add all the common game options to all runner classes"""
for item in self.common_game_options:
if item not in self.game_options:
self.game_options.append(item)

View file

@ -325,7 +325,7 @@ class steam(Runner):
else:
command.append("-applaunch")
command.append(self.appid)
if game_args:
for arg in shlex.split(game_args):
command.append(arg)

View file

@ -37,5 +37,7 @@ ICON_URL = SITE_URL + "/games/icon/%s.png"
BANNER_URL = SITE_URL + "/games/banner/%s.jpg"
RUNTIME_URL = "https://lutris.net/api/runtime"
DEFAULT_DISCORD_CLIENT_ID = "576189919748161556"
read_setting = sio.read_setting
write_setting = sio.write_setting

View file

@ -50,6 +50,7 @@ setup(
'PyYAML',
'PyGObject',
'evdev',
'pypresence',
'requests'
],
url='https://lutris.net',