Support for specifying Discord IPC pipe number

This commit is contained in:
Phin 2023-11-05 15:54:36 +05:30
parent d6ac5e5e90
commit 453855b1c3
6 changed files with 76 additions and 43 deletions

View file

@ -51,6 +51,7 @@ The config file is stored in a directory named `data`.
* `listenForUser` (string, optional) - The script reacts to alerts originating only from this username. Defaults to the parent user's username if not set. * `listenForUser` (string, optional) - The script reacts to alerts originating only from this username. Defaults to the parent user's username if not set.
* `blacklistedLibraries` (list, optional) - Alerts originating from libraries in this list are ignored. * `blacklistedLibraries` (list, optional) - Alerts originating from libraries in this list are ignored.
* `whitelistedLibraries` (list, optional) - If set, alerts originating from libraries that are not in this list are ignored. * `whitelistedLibraries` (list, optional) - If set, alerts originating from libraries that are not in this list are ignored.
* `ipcPipeNumber` (int, optional) - A number in the range of `0-9` to specify the Discord IPC pipe to connect to. Defaults to `-1`, which specifies that the first existing pipe in the range should be used. When a Discord client is launched, it binds to the first unbound pipe number, which is typically `0`.
### Obtaining an Imgur client ID ### Obtaining an Imgur client ID

View file

@ -2,7 +2,7 @@ import os
import sys import sys
name = "Discord Rich Presence for Plex" name = "Discord Rich Presence for Plex"
version = "2.4.1" version = "2.4.2"
plexClientID = "discord-rich-presence-plex" plexClientID = "discord-rich-presence-plex"
discordClientID = "413407336082833418" discordClientID = "413407336082833418"

View file

@ -10,27 +10,41 @@ import time
class DiscordIpcService: class DiscordIpcService:
def __init__(self): def __init__(self, ipcPipeNumber: Optional[int]):
self.ipcPipe = (("/run/app" if os.path.isdir("/run/app") else os.environ.get("XDG_RUNTIME_DIR", os.environ.get("TMPDIR", os.environ.get("TMP", os.environ.get("TEMP", "/tmp"))))) + "/discord-ipc-0") if isUnix else r"\\?\pipe\discord-ipc-0" ipcPipeNumber = ipcPipeNumber or -1
self.loop: asyncio.AbstractEventLoop = None # pyright: ignore[reportGeneralTypeIssues] ipcPipeNumbers = range(10) if ipcPipeNumber == -1 else [ipcPipeNumber]
self.pipeReader: asyncio.StreamReader = None # pyright: ignore[reportGeneralTypeIssues] ipcPipeBase = ("/run/app" if os.path.isdir("/run/app") else os.environ.get("XDG_RUNTIME_DIR", os.environ.get("TMPDIR", os.environ.get("TMP", os.environ.get("TEMP", "/tmp"))))) if isUnix else r"\\?\pipe"
self.pipeWriter: asyncio.StreamWriter = None # pyright: ignore[reportGeneralTypeIssues] self.ipcPipes = [os.path.join(ipcPipeBase, f"discord-ipc-{ipcPipeNumber}") for ipcPipeNumber in ipcPipeNumbers]
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.pipeReader: Optional[asyncio.StreamReader] = None
self.pipeWriter: Optional[asyncio.StreamWriter] = None
self.connected = False self.connected = False
async def handshake(self) -> None: async def handshake(self) -> None:
try: if not self.loop:
if isUnix: return
self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(self.ipcPipe) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] for ipcPipe in self.ipcPipes:
else: try:
self.pipeReader = asyncio.StreamReader() if isUnix:
self.pipeWriter = (await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader), self.ipcPipe))[0] # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(ipcPipe) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
self.write(0, { "v": 1, "client_id": discordClientID }) else:
if await self.read(): self.pipeReader = asyncio.StreamReader()
self.connected = True self.pipeWriter = (await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader), ipcPipe))[0] # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
except: self.write(0, { "v": 1, "client_id": discordClientID })
logger.exception("An unexpected error occured during an IPC handshake operation") if await self.read():
self.connected = True
logger.info(f"Connected to Discord IPC pipe {ipcPipe}")
break
except FileNotFoundError:
pass
except:
logger.exception(f"An unexpected error occured while connecting to Discord IPC pipe {ipcPipe}")
if not self.connected:
logger.error(f"Discord IPC pipe not found (attempted pipes: {', '.join(self.ipcPipes)})")
async def read(self) -> Optional[Any]: async def read(self) -> Optional[Any]:
if not self.pipeReader:
return
try: try:
dataBytes = await self.pipeReader.read(1024) dataBytes = await self.pipeReader.read(1024)
data = json.loads(dataBytes[8:].decode("utf-8")) data = json.loads(dataBytes[8:].decode("utf-8"))
@ -41,6 +55,8 @@ class DiscordIpcService:
self.connected = False self.connected = False
def write(self, op: int, payload: Any) -> None: def write(self, op: int, payload: Any) -> None:
if not self.pipeWriter:
return
try: try:
logger.debug("[WRITE] %s", payload) logger.debug("[WRITE] %s", payload)
payload = json.dumps(payload) payload = json.dumps(payload)
@ -51,17 +67,19 @@ class DiscordIpcService:
def connect(self) -> None: def connect(self) -> None:
if self.connected: if self.connected:
logger.debug("Attempt to connect Discord IPC pipe while already connected") logger.warning("Attempt to connect to Discord IPC pipe while already connected")
return return
logger.info("Connecting Discord IPC pipe") logger.info("Connecting to Discord IPC pipe")
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
self.loop.run_until_complete(self.handshake()) self.loop.run_until_complete(self.handshake())
def disconnect(self) -> None: def disconnect(self) -> None:
if not self.connected: if not self.connected:
logger.debug("Attempt to disconnect Discord IPC pipe while not connected") logger.warning("Attempt to disconnect from Discord IPC pipe while not connected")
return return
logger.info("Disconnecting Discord IPC pipe") if not self.loop or not self.pipeWriter or not self.pipeReader:
return
logger.info("Disconnecting from Discord IPC pipe")
try: try:
self.pipeWriter.close() self.pipeWriter.close()
except: except:
@ -77,6 +95,11 @@ class DiscordIpcService:
self.connected = False self.connected = False
def setActivity(self, activity: models.discord.Activity) -> None: def setActivity(self, activity: models.discord.Activity) -> None:
if not self.connected:
logger.warning("Attempt to set activity while not connected to Discord IPC pipe")
return
if not self.loop:
return
logger.info("Activity update: %s", activity) logger.info("Activity update: %s", activity)
payload = { payload = {
"cmd": "SET_ACTIVITY", "cmd": "SET_ACTIVITY",

View file

@ -47,7 +47,7 @@ class PlexAlertListener(threading.Thread):
self.token = token self.token = token
self.serverConfig = serverConfig self.serverConfig = serverConfig
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}] ") # pyright: ignore[reportTypedDictNotRequiredAccess] self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}] ") # pyright: ignore[reportTypedDictNotRequiredAccess]
self.discordIpcService = DiscordIpcService() self.discordIpcService = DiscordIpcService(self.serverConfig.get("ipcPipeNumber"))
self.updateTimeoutTimer: Optional[threading.Timer] = None self.updateTimeoutTimer: Optional[threading.Timer] = None
self.connectionTimeoutTimer: Optional[threading.Timer] = None self.connectionTimeoutTimer: Optional[threading.Timer] = None
self.account: Optional[MyPlexAccount] = None self.account: Optional[MyPlexAccount] = None
@ -63,22 +63,22 @@ class PlexAlertListener(threading.Thread):
try: try:
self.logger.info("Signing into Plex") self.logger.info("Signing into Plex")
self.account = MyPlexAccount(token = self.token) self.account = MyPlexAccount(token = self.token)
self.logger.info("Signed in as Plex user \"%s\"", self.account.username) self.logger.info("Signed in as Plex user '%s'", self.account.username)
self.listenForUser = self.serverConfig.get("listenForUser", "") or self.account.username self.listenForUser = self.serverConfig.get("listenForUser", "") or self.account.username
self.server = None self.server = None
for resource in self.account.resources(): for resource in self.account.resources():
if resource.product == self.productName and resource.name.lower() == self.serverConfig["name"].lower(): if resource.product == self.productName and resource.name.lower() == self.serverConfig["name"].lower():
self.logger.info("Connecting to %s \"%s\"", self.productName, self.serverConfig["name"]) self.logger.info("Connecting to %s '%s'", self.productName, self.serverConfig["name"])
self.server = resource.connect() self.server = resource.connect()
try: try:
self.server.account() self.server.account()
self.isServerOwner = True self.isServerOwner = True
except: except:
pass pass
self.logger.info("Connected to %s \"%s\"", self.productName, resource.name) self.logger.info("Connected to %s '%s'", self.productName, resource.name)
self.alertListener = AlertListener(self.server, self.handleAlert, self.reconnect) self.alertListener = AlertListener(self.server, self.handleAlert, self.reconnect)
self.alertListener.start() self.alertListener.start()
self.logger.info("Listening for alerts from user \"%s\"", self.listenForUser) self.logger.info("Listening for alerts from user '%s'", self.listenForUser)
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout) self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
self.connectionTimeoutTimer.start() self.connectionTimeoutTimer.start()
connected = True connected = True
@ -86,7 +86,7 @@ class PlexAlertListener(threading.Thread):
if not self.server: if not self.server:
raise Exception("Server not found") raise Exception("Server not found")
except Exception as e: except Exception as e:
self.logger.error("Failed to connect to %s \"%s\": %s", self.productName, self.serverConfig["name"], e) # pyright: ignore[reportTypedDictNotRequiredAccess] self.logger.error("Failed to connect to %s '%s': %s", self.productName, self.serverConfig["name"], e) # pyright: ignore[reportTypedDictNotRequiredAccess]
self.logger.error("Reconnecting in 10 seconds") self.logger.error("Reconnecting in 10 seconds")
time.sleep(10) time.sleep(10)
@ -108,7 +108,8 @@ class PlexAlertListener(threading.Thread):
def disconnectRpc(self) -> None: def disconnectRpc(self) -> None:
self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0 self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0
self.discordIpcService.disconnect() if self.discordIpcService.connected:
self.discordIpcService.disconnect()
self.cancelTimers() self.cancelTimers()
def cancelTimers(self) -> None: def cancelTimers(self) -> None:
@ -145,10 +146,10 @@ class PlexAlertListener(threading.Thread):
item: PlexPartialObject = self.server.fetchItem(ratingKey) item: PlexPartialObject = self.server.fetchItem(ratingKey)
libraryName: str = item.section().title libraryName: str = item.section().title
if "blacklistedLibraries" in self.serverConfig and libraryName in self.serverConfig["blacklistedLibraries"]: if "blacklistedLibraries" in self.serverConfig and libraryName in self.serverConfig["blacklistedLibraries"]:
self.logger.debug("Library \"%s\" is blacklisted, ignoring", libraryName) self.logger.debug("Library '%s' is blacklisted, ignoring", libraryName)
return return
if "whitelistedLibraries" in self.serverConfig and libraryName not in self.serverConfig["whitelistedLibraries"]: if "whitelistedLibraries" in self.serverConfig and libraryName not in self.serverConfig["whitelistedLibraries"]:
self.logger.debug("Library \"%s\" is not whitelisted, ignoring", libraryName) self.logger.debug("Library '%s' is not whitelisted, ignoring", libraryName)
return return
if self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey: if self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey:
if self.updateTimeoutTimer: if self.updateTimeoutTimer:
@ -166,7 +167,7 @@ class PlexAlertListener(threading.Thread):
self.disconnectRpc() self.disconnectRpc()
return return
elif state == "stopped": elif state == "stopped":
self.logger.debug("Received \"stopped\" state alert from unknown session, ignoring") self.logger.debug("Received 'stopped' state alert from unknown session, ignoring")
return return
if self.isServerOwner: if self.isServerOwner:
self.logger.debug("Searching sessions for session key %s", sessionKey) self.logger.debug("Searching sessions for session key %s", sessionKey)
@ -180,9 +181,9 @@ class PlexAlertListener(threading.Thread):
self.logger.debug("Session found") self.logger.debug("Session found")
sessionUsername: str = session.usernames[0] sessionUsername: str = session.usernames[0]
if sessionUsername.lower() == self.listenForUser.lower(): if sessionUsername.lower() == self.listenForUser.lower():
self.logger.debug("Username \"%s\" matches \"%s\", continuing", sessionUsername, self.listenForUser) self.logger.debug("Username '%s' matches '%s', continuing", sessionUsername, self.listenForUser)
break break
self.logger.debug("Username \"%s\" doesn't match \"%s\", ignoring", sessionUsername, self.listenForUser) self.logger.debug("Username '%s' doesn't match '%s', ignoring", sessionUsername, self.listenForUser)
return return
else: else:
self.logger.debug("No matching session found, ignoring") self.logger.debug("No matching session found, ignoring")
@ -221,7 +222,7 @@ class PlexAlertListener(threading.Thread):
largeText = "Listening to music" largeText = "Listening to music"
thumb = item.thumb thumb = item.thumb
else: else:
self.logger.debug("Unsupported media type \"%s\", ignoring", mediaType) self.logger.debug("Unsupported media type '%s', ignoring", mediaType)
return return
thumbUrl = "" thumbUrl = ""
if thumb and config["display"]["posters"]["enabled"]: if thumb and config["display"]["posters"]["enabled"]:

25
main.py
View file

@ -38,7 +38,7 @@ import logging
import models.config import models.config
import time import time
def main() -> None: def init() -> None:
if not os.path.exists(dataDirectoryPath): if not os.path.exists(dataDirectoryPath):
os.mkdir(dataDirectoryPath) os.mkdir(dataDirectoryPath)
for oldFilePath in ["config.json", "cache.json", "console.log"]: for oldFilePath in ["config.json", "cache.json", "console.log"]:
@ -53,6 +53,9 @@ def main() -> None:
logger.addHandler(fileHandler) logger.addHandler(fileHandler)
logger.info("%s - v%s", name, version) logger.info("%s - v%s", name, version)
loadCache() loadCache()
def main() -> None:
init()
if not config["users"]: if not config["users"]:
logger.info("No users found in the config file") logger.info("No users found in the config file")
user = authNewUser() user = authNewUser()
@ -92,9 +95,10 @@ def authNewUser() -> Optional[models.config.User]:
else: else:
logger.info(f"Authentication timed out ({formatSeconds(180)})") logger.info(f"Authentication timed out ({formatSeconds(180)})")
def testIpc() -> None: def testIpc(ipcPipeNumber: int) -> None:
init()
logger.info("Testing Discord IPC connection") logger.info("Testing Discord IPC connection")
discordIpcService = DiscordIpcService() discordIpcService = DiscordIpcService(ipcPipeNumber)
discordIpcService.connect() discordIpcService.connect()
discordIpcService.setActivity({ discordIpcService.setActivity({
"details": "details", "details": "details",
@ -111,9 +115,12 @@ def testIpc() -> None:
if __name__ == "__main__": if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "" mode = sys.argv[1] if len(sys.argv) > 1 else ""
if not mode: try:
main() if not mode:
elif mode == "test-ipc": main()
testIpc() elif mode == "test-ipc":
else: testIpc(int(sys.argv[2]) if len(sys.argv) > 2 else -1)
print(f"Invalid mode: {mode}") else:
print(f"Invalid mode: {mode}")
except KeyboardInterrupt:
pass

View file

@ -23,6 +23,7 @@ class Server(TypedDict, total = False):
listenForUser: str listenForUser: str
blacklistedLibraries: list[str] blacklistedLibraries: list[str]
whitelistedLibraries: list[str] whitelistedLibraries: list[str]
ipcPipeNumber: int
class User(TypedDict): class User(TypedDict):
token: str token: str