diff --git a/README.md b/README.md index 5b1be22..5f485b9 100644 --- a/README.md +++ b/README.md @@ -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. * `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. + * `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 diff --git a/config/constants.py b/config/constants.py index 1621159..d2f09c2 100644 --- a/config/constants.py +++ b/config/constants.py @@ -2,7 +2,7 @@ import os import sys name = "Discord Rich Presence for Plex" -version = "2.4.1" +version = "2.4.2" plexClientID = "discord-rich-presence-plex" discordClientID = "413407336082833418" diff --git a/core/discord.py b/core/discord.py index 7c94c65..b716cae 100644 --- a/core/discord.py +++ b/core/discord.py @@ -10,27 +10,41 @@ import time class DiscordIpcService: - def __init__(self): - 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" - self.loop: asyncio.AbstractEventLoop = None # pyright: ignore[reportGeneralTypeIssues] - self.pipeReader: asyncio.StreamReader = None # pyright: ignore[reportGeneralTypeIssues] - self.pipeWriter: asyncio.StreamWriter = None # pyright: ignore[reportGeneralTypeIssues] + def __init__(self, ipcPipeNumber: Optional[int]): + ipcPipeNumber = ipcPipeNumber or -1 + ipcPipeNumbers = range(10) if ipcPipeNumber == -1 else [ipcPipeNumber] + 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.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 async def handshake(self) -> None: - try: - if isUnix: - self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(self.ipcPipe) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] - else: - self.pipeReader = asyncio.StreamReader() - self.pipeWriter = (await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader), self.ipcPipe))[0] # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] - self.write(0, { "v": 1, "client_id": discordClientID }) - if await self.read(): - self.connected = True - except: - logger.exception("An unexpected error occured during an IPC handshake operation") + if not self.loop: + return + for ipcPipe in self.ipcPipes: + try: + if isUnix: + self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(ipcPipe) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] + else: + self.pipeReader = asyncio.StreamReader() + self.pipeWriter = (await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader), ipcPipe))[0] # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] + self.write(0, { "v": 1, "client_id": discordClientID }) + 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]: + if not self.pipeReader: + return try: dataBytes = await self.pipeReader.read(1024) data = json.loads(dataBytes[8:].decode("utf-8")) @@ -41,6 +55,8 @@ class DiscordIpcService: self.connected = False def write(self, op: int, payload: Any) -> None: + if not self.pipeWriter: + return try: logger.debug("[WRITE] %s", payload) payload = json.dumps(payload) @@ -51,17 +67,19 @@ class DiscordIpcService: def connect(self) -> None: 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 - logger.info("Connecting Discord IPC pipe") + logger.info("Connecting to Discord IPC pipe") self.loop = asyncio.new_event_loop() self.loop.run_until_complete(self.handshake()) def disconnect(self) -> None: 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 - 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: self.pipeWriter.close() except: @@ -77,6 +95,11 @@ class DiscordIpcService: self.connected = False 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) payload = { "cmd": "SET_ACTIVITY", diff --git a/core/plex.py b/core/plex.py index 800ca3c..72dee90 100644 --- a/core/plex.py +++ b/core/plex.py @@ -47,7 +47,7 @@ class PlexAlertListener(threading.Thread): self.token = token self.serverConfig = serverConfig 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.connectionTimeoutTimer: Optional[threading.Timer] = None self.account: Optional[MyPlexAccount] = None @@ -63,22 +63,22 @@ class PlexAlertListener(threading.Thread): try: self.logger.info("Signing into Plex") 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.server = None for resource in self.account.resources(): 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() try: self.server.account() self.isServerOwner = True except: 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.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.start() connected = True @@ -86,7 +86,7 @@ class PlexAlertListener(threading.Thread): if not self.server: raise Exception("Server not found") 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") time.sleep(10) @@ -108,7 +108,8 @@ class PlexAlertListener(threading.Thread): def disconnectRpc(self) -> None: self.lastState, self.lastSessionKey, self.lastRatingKey = "", 0, 0 - self.discordIpcService.disconnect() + if self.discordIpcService.connected: + self.discordIpcService.disconnect() self.cancelTimers() def cancelTimers(self) -> None: @@ -145,10 +146,10 @@ class PlexAlertListener(threading.Thread): item: PlexPartialObject = self.server.fetchItem(ratingKey) libraryName: str = item.section().title 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 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 if self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey: if self.updateTimeoutTimer: @@ -166,7 +167,7 @@ class PlexAlertListener(threading.Thread): self.disconnectRpc() return 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 if self.isServerOwner: self.logger.debug("Searching sessions for session key %s", sessionKey) @@ -180,9 +181,9 @@ class PlexAlertListener(threading.Thread): self.logger.debug("Session found") sessionUsername: str = session.usernames[0] 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 - 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 else: self.logger.debug("No matching session found, ignoring") @@ -221,7 +222,7 @@ class PlexAlertListener(threading.Thread): largeText = "Listening to music" thumb = item.thumb else: - self.logger.debug("Unsupported media type \"%s\", ignoring", mediaType) + self.logger.debug("Unsupported media type '%s', ignoring", mediaType) return thumbUrl = "" if thumb and config["display"]["posters"]["enabled"]: diff --git a/main.py b/main.py index 1cb9105..b07d9b7 100644 --- a/main.py +++ b/main.py @@ -38,7 +38,7 @@ import logging import models.config import time -def main() -> None: +def init() -> None: if not os.path.exists(dataDirectoryPath): os.mkdir(dataDirectoryPath) for oldFilePath in ["config.json", "cache.json", "console.log"]: @@ -53,6 +53,9 @@ def main() -> None: logger.addHandler(fileHandler) logger.info("%s - v%s", name, version) loadCache() + +def main() -> None: + init() if not config["users"]: logger.info("No users found in the config file") user = authNewUser() @@ -92,9 +95,10 @@ def authNewUser() -> Optional[models.config.User]: else: logger.info(f"Authentication timed out ({formatSeconds(180)})") -def testIpc() -> None: +def testIpc(ipcPipeNumber: int) -> None: + init() logger.info("Testing Discord IPC connection") - discordIpcService = DiscordIpcService() + discordIpcService = DiscordIpcService(ipcPipeNumber) discordIpcService.connect() discordIpcService.setActivity({ "details": "details", @@ -111,9 +115,12 @@ def testIpc() -> None: if __name__ == "__main__": mode = sys.argv[1] if len(sys.argv) > 1 else "" - if not mode: - main() - elif mode == "test-ipc": - testIpc() - else: - print(f"Invalid mode: {mode}") + try: + if not mode: + main() + elif mode == "test-ipc": + testIpc(int(sys.argv[2]) if len(sys.argv) > 2 else -1) + else: + print(f"Invalid mode: {mode}") + except KeyboardInterrupt: + pass diff --git a/models/config.py b/models/config.py index b848ae6..3a86cc9 100644 --- a/models/config.py +++ b/models/config.py @@ -23,6 +23,7 @@ class Server(TypedDict, total = False): listenForUser: str blacklistedLibraries: list[str] whitelistedLibraries: list[str] + ipcPipeNumber: int class User(TypedDict): token: str