diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d344ba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json diff --git a/LICENSE b/LICENSE index 33de8a8..6a68d56 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 phin05 +Copyright (c) 2018-2022 phin05 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 613d5de..9244c16 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,71 @@ # Discord Rich Presence for Plex -A Python script that displays your [Plex](https://www.plex.tv) status on [Discord](https://discordapp.com) using [Rich Presence](https://discordapp.com/developers/docs/rich-presence/how-to). +A Python script that displays your [Plex](https://www.plex.tv) status on [Discord](https://discord.com) using [Rich Presence](https://discord.com/developers/docs/rich-presence/how-to). -## Requirements +## Getting Started -* [Python 3.6.7](https://www.python.org/downloads/release/python-367/) -* [plexapi](https://github.com/pkkid/python-plexapi) -* Use [websocket-client](https://github.com/websocket-client/websocket-client) version 0.48.0 (`pip install websocket-client==0.48.0`) as an issue with newer versions breaks the plexapi module's alert listener. -* The script must be running on the same machine as your Discord client. +1. Install [Python 3.10](https://www.python.org/downloads/) +2. [Download](archive/refs/heads/master.zip) the ZIP file containing the files in this repository +3. Extract the contents of the above ZIP file into a new directory +4. Navigate a command-line interface (cmd.exe, PowerShell, bash, etc.) to the above directory +5. Install the required Python modules by running `python -m pip install -r requirements.txt` +6. Start the script by running `python main.py` -## Configuration +When the script runs for the first time, a `config.json` file will be created in the working directory and you will be prompted to complete the authentication flow to allow the script to retrieve your username and an access token. -Add your configuration(s) into the `plexConfigs` list on line 30. +The script must be running on the same machine as your Discord client. -#### Example +## Configuration - `config.json` -```python -plexConfigs = [ - plexConfig(serverName = "ABC", username = "xyz", password = "0tYD4UIC4Tb8X0nt"), - plexConfig(serverName = "DEF", username = "pqr@pqr.pqr", token = "70iU3GZrI54S76Tn", listenForUser = "xyz"), - plexConfig(serverName = "GHI", username = "xyz", password = "0tYD4UIC4Tb8X0nt", blacklistedLibraries = ["TV Shows", "Music"]) -] +### Reference + +* `logging` + * `debug` - Output more information to the console +* `display` + * `useRemainingTime` - Display remaining time in your Rich Presence instead of elapsed time +* `users` (list) + * `username` - Username or e-mail + * `token` - A [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token) + * `servers` (list) + * `name` - Friendly name of the Plex Media Server you wish to connect to. + * `blacklistedLibraries` (optional list) - Alerts originating from libraries in this list are ignored. + * `whitelistedLibraries` (optional list) - If set, alerts originating from libraries that are not in this list are ignored. + +### Example + +```json +{ + "logging": { + "debug": true + }, + "display": { + "useRemainingTime": false + }, + "users": [ + { + "username": "bob", + "token": "HPbrz2NhfLRjU888Rrdt", + "servers": [ + { + "name": "Bob's Home Media Server", + }, + { + "name": "A Friend's Server", + "whitelistedLibraries": ["Movies"] + } + ] + } + ] +} ``` -#### Parameters - -* `serverName` - Name of the Plex Media Server to connect to. -* `username` - Your account's username or e-mail. -* `password` (not required if `token` is set) - The password associated with the above account. -* `token` (not required if `password` is set) - A [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token) associated with the above account. In some cases, `myPlexAccessToken` from Plex Web App's HTML5 Local Storage must be used. To retrieve this token in Google Chrome, open Plex Web App, press F12, go to "Application", expand "Local Storage" and select the relevant entry. Ignores `password` if set. -* `listenForUser` (optional) - The script will respond to alerts originating only from this username. Defaults to `username` if not set. -* `blacklistedLibraries` (list, optional) - Alerts originating from blacklisted libraries are ignored. -* `whitelistedLibraries` (list, optional) - If set, alerts originating from libraries that are not in the whitelist are ignored. - -### Other Variables - -* Line 16: `extraLogging` - The script outputs more information if this is set to `True`. -* Line 17: `timeRemaining` - Set this to `True` to display time remaining instead of time elapsed while media is playing. - ## License -This project is licensed under the MIT License. See the [LICENSE](https://github.com/phin05/discord-rich-presence-plex/blob/master/LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Credits + +* [Discord](https://discord.com) +* [Plex](https://www.plex.tv) +* [plexapi](https://github.com/pkkid/python-plexapi) +* [websocket-client](https://github.com/websocket-client/websocket-client) diff --git a/discordRichPresencePlex.py b/discordRichPresencePlex.py deleted file mode 100644 index 2953014..0000000 --- a/discordRichPresencePlex.py +++ /dev/null @@ -1,396 +0,0 @@ -import asyncio -import datetime -import hashlib -import json -import os -import plexapi.myplex -import struct -import subprocess -import sys -import tempfile -import threading -import time - -class plexConfig: - - extraLogging = True - timeRemaining = False - - def __init__(self, serverName = "", username = "", password = "", token = "", listenForUser = "", blacklistedLibraries = None, whitelistedLibraries = None, clientID = "413407336082833418"): - self.serverName = serverName - self.username = username - self.password = password - self.token = token - self.listenForUser = (username if listenForUser == "" else listenForUser).lower() - self.blacklistedLibraries = blacklistedLibraries - self.whitelistedLibraries = whitelistedLibraries - self.clientID = clientID - -plexConfigs = [ - # plexConfig(serverName = "", username = "", password = "", token = "") -] - -class discordRichPresence: - - def __init__(self, clientID, child): - self.IPCPipe = ((os.environ.get("XDG_RUNTIME_DIR", None) or os.environ.get("TMPDIR", None) or os.environ.get("TMP", None) or os.environ.get("TEMP", None) or "/tmp") + "/discord-ipc-0") if isLinux else "\\\\?\\pipe\\discord-ipc-0" - self.clientID = clientID - self.pipeReader = None - self.pipeWriter = None - self.process = None - self.running = False - self.child = child - - async def read(self): - try: - data = await self.pipeReader.read(1024) - self.child.log("[READ] " + str(json.loads(data[8:].decode("utf-8")))) - except Exception as e: - self.child.log("[READ] " + str(e)) - self.stop() - - def write(self, op, payload): - payload = json.dumps(payload) - self.child.log("[WRITE] " + str(payload)) - data = self.pipeWriter.write(struct.pack(" 0] - else: - if (text["h"] == 0): - del text["h"] - text = [str(v).rjust(2, "0") for k, v in text.items()] - return joiner.join(text) - -discordRichPresencePlexInstances = [] -for config in plexConfigs: - discordRichPresencePlexInstances.append(discordRichPresencePlex(config)) -try: - for discordRichPresencePlexInstance in discordRichPresencePlexInstances: - discordRichPresencePlexInstance.run() - while (True): - time.sleep(3600) -except KeyboardInterrupt: - for discordRichPresencePlexInstance in discordRichPresencePlexInstances: - discordRichPresencePlexInstance.reset() -except Exception as e: - print("Error: " + str(e)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d7cb3ba --- /dev/null +++ b/main.py @@ -0,0 +1,34 @@ +from services import ConfigService, PlexAlertListener +from store.constants import isUnix +from utils.logs import logger +import logging +import os + +os.system("clear" if isUnix else "cls") + +configService = ConfigService("config.json") +config = configService.config + +if config["logging"]["debug"]: + logger.setLevel(logging.DEBUG) + +PlexAlertListener.useRemainingTime = config["display"]["useRemainingTime"] + +if len(config["users"]) == 0: + logger.info("No users in config. Initiating authorisation flow. ! TBD !") # TODO + exit() + +plexAlertListeners: list[PlexAlertListener] = [] +try: + for user in config["users"]: + for server in user["servers"]: + plexAlertListeners.append(PlexAlertListener(user["username"], user["token"], server)) + while True: + userInput = input() + if userInput in ["exit", "quit"]: + break +except KeyboardInterrupt: + for plexAlertListener in plexAlertListeners: + plexAlertListener.disconnect() +except: + logger.exception("An unexpected error occured") diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/config.py b/models/config.py new file mode 100644 index 0000000..e82b130 --- /dev/null +++ b/models/config.py @@ -0,0 +1,22 @@ +from typing import TypedDict + +class Logging(TypedDict): + debug: bool + +class Display(TypedDict): + useRemainingTime: bool + +class Server(TypedDict): + name: str + blacklistedLibraries: list[str] + whitelistedLibraries: list[str] + +class User(TypedDict): + username: str + token: str + servers: list[Server] + +class Config(TypedDict): + logging: Logging + display: Display + users: list[User] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e14dba4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +plexapi +websocket-client diff --git a/services/ConfigService.py b/services/ConfigService.py new file mode 100644 index 0000000..fa8bd18 --- /dev/null +++ b/services/ConfigService.py @@ -0,0 +1,41 @@ +from datetime import datetime +from models.config import Config +from utils.logs import logger +import json +import os + +class ConfigService: + + config: Config + + def __init__(self, configFilePath: str) -> None: + self.configFilePath = configFilePath + if os.path.isfile(self.configFilePath): + try: + with open(self.configFilePath, "r", encoding = "UTF-8") as configFile: + self.config = json.load(configFile) + except: + os.rename(configFilePath, configFilePath.replace(".json", f"-{datetime.now().timestamp():.0f}.json")) + logger.exception("Failed to parse the application's config file. A new one will be created.") + self.resetConfig() + else: + self.resetConfig() + + def resetConfig(self) -> None: + self.config = { + "logging": { + "debug": True + }, + "display": { + "useRemainingTime": False + }, + "users": [] + } + self.saveConfig() + + def saveConfig(self) -> None: + try: + with open(self.configFilePath, "w", encoding = "UTF-8") as configFile: + json.dump(self.config, configFile, indent = "\t") + except: + logger.exception("Failed to write to the application's config file.\n%s") diff --git a/services/DiscordRpcService.py b/services/DiscordRpcService.py new file mode 100644 index 0000000..ee03863 --- /dev/null +++ b/services/DiscordRpcService.py @@ -0,0 +1,90 @@ +# type: ignore + +from store.constants import isUnix, processID +from utils.logs import logger +import asyncio +import json +import os +import struct +import time + +class DiscordRpcService: + + clientID = "413407336082833418" + ipcPipe = ((os.environ.get("XDG_RUNTIME_DIR", None) or os.environ.get("TMPDIR", None) or os.environ.get("TMP", None) or os.environ.get("TEMP", None) or "/tmp") + "/discord-ipc-0") if isUnix else r"\\?\pipe\discord-ipc-0" + + def __init__(self): + self.loop = None + self.pipeReader = None + self.pipeWriter = None + self.connected = False + + def connect(self): + logger.info("Connecting Discord IPC Pipe") + self.loop = asyncio.new_event_loop() if isUnix else asyncio.ProactorEventLoop() + self.loop.run_until_complete(self.handshake()) + + async def handshake(self): + try: + if isUnix: + self.pipeReader, self.pipeWriter = await asyncio.open_unix_connection(self.ipcPipe, loop = self.loop) + else: + self.pipeReader = asyncio.StreamReader(loop = self.loop) + self.pipeWriter, _ = await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader, loop = self.loop), self.ipcPipe) + self.write(0, { "v": 1, "client_id": self.clientID }) + if await self.read(): + self.connected = True + except: + logger.exception("An unexpected error occured during a RPC handshake operation") + + async def read(self): + try: + dataBytes = await self.pipeReader.read(1024) + data = json.loads(dataBytes[8:].decode("utf-8")) + logger.debug("[READ] %s", data) + return data + except: + logger.exception("An unexpected error occured during a RPC read operation") + self.disconnect() + + def write(self, op, payload): + try: + logger.debug("[WRITE] %s", payload) + payload = json.dumps(payload) + self.pipeWriter.write(struct.pack(" None: + self.prefix = prefix + + def info(self, obj: Any, *args: Any, **kwargs: Any) -> None: + logger.info(self.prefix + str(obj), *args, **kwargs) + + def warning(self, obj: Any, *args: Any, **kwargs: Any) -> None: + logger.warning(self.prefix + str(obj), *args, **kwargs) + + def error(self, obj: Any, *args: Any, **kwargs: Any) -> None: + logger.error(self.prefix + str(obj), *args, **kwargs) + + def exception(self, obj: Any, *args: Any, **kwargs: Any) -> None: + logger.exception(self.prefix + str(obj), *args, **kwargs) + + def debug(self, obj: Any, *args: Any, **kwargs: Any) -> None: + logger.debug(self.prefix + str(obj), *args, **kwargs) diff --git a/utils/text.py b/utils/text.py new file mode 100644 index 0000000..72d78f7 --- /dev/null +++ b/utils/text.py @@ -0,0 +1,8 @@ +def formatSeconds(seconds: int, joiner: str = "") -> str: + seconds = round(seconds) + timeValues = {"h": seconds // 3600, "m": seconds // 60 % 60, "s": seconds % 60} + if (not joiner): + return "".join(str(v) + k for k, v in timeValues.items() if v > 0) + if (timeValues["h"] == 0): + del timeValues["h"] + return joiner.join(str(v).rjust(2, "0") for v in timeValues.values())