Major refactor

This commit is contained in:
Phin 2022-05-11 01:53:12 +05:30
parent e58a39b483
commit 6467ae2a80
17 changed files with 525 additions and 428 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
config.json

View file

@ -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

View file

@ -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)

View file

@ -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("<ii", op, len(payload)) + payload.encode("utf-8"))
async def handshake(self):
try:
if (isLinux):
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})
await self.read()
self.running = True
except Exception as e:
self.child.log("[HANDSHAKE] " + str(e))
def start(self):
self.child.log("Opening Discord IPC Pipe")
emptyProcessFilePath = tempfile.gettempdir() + ("/" if isLinux else "\\") + "discordRichPresencePlex-emptyProcess.py"
if (not os.path.exists(emptyProcessFilePath)):
with open(emptyProcessFilePath, "w") as emptyProcessFile:
emptyProcessFile.write("import time\n\ntry:\n\twhile (True):\n\t\ttime.sleep(3600)\nexcept:\n\tpass")
self.process = subprocess.Popen(["python3" if isLinux else "pythonw", emptyProcessFilePath])
self.loop = asyncio.new_event_loop() if isLinux else asyncio.ProactorEventLoop()
self.loop.run_until_complete(self.handshake())
def stop(self):
self.child.log("Closing Discord IPC Pipe")
self.child.lastState, self.child.lastSessionKey, self.child.lastRatingKey = None, None, None
self.process.kill()
if (self.child.stopTimer):
self.child.stopTimer.cancel()
self.child.stopTimer = None
if (self.child.stopTimer2):
self.child.stopTimer2.cancel()
self.child.stopTimer2 = None
if (self.pipeWriter):
try:
self.pipeWriter.close()
except:
pass
self.pipeWriter = None
if (self.pipeReader):
try:
self.loop.run_until_complete(self.pipeReader.read(1024))
except:
pass
self.pipeReader = None
try:
self.loop.close()
except:
pass
self.running = False
def send(self, activity):
payload = {
"cmd": "SET_ACTIVITY",
"args": {
"activity": activity,
"pid": self.process.pid
},
"nonce": "{0:.20f}".format(time.time())
}
self.write(1, payload)
self.loop.run_until_complete(self.read())
class discordRichPresencePlex(discordRichPresence):
productName = "Plex Media Server"
stopTimerInterval = 5
stopTimer2Interval = 35
checkConnectionTimerInterval = 60
maximumIgnores = 3
def __init__(self, plexConfig):
self.plexConfig = plexConfig
self.instanceID = hashlib.md5(str(id(self)).encode("UTF-8")).hexdigest()[:5]
super().__init__(plexConfig.clientID, self)
self.plexAccount = None
self.plexServer = None
self.isServerOwner = False
self.plexAlertListener = None
self.lastState = None
self.lastSessionKey = None
self.lastRatingKey = None
self.stopTimer = None
self.stopTimer2 = None
self.checkConnectionTimer = None
self.ignoreCount = 0
def run(self):
self.reset()
connected = False
while (not connected):
try:
if (self.plexConfig.token):
self.plexAccount = plexapi.myplex.MyPlexAccount(self.plexConfig.username, token = self.plexConfig.token)
else:
self.plexAccount = plexapi.myplex.MyPlexAccount(self.plexConfig.username, self.plexConfig.password)
self.log("Logged in as Plex User \"" + self.plexAccount.username + "\"")
self.plexServer = None
for resource in self.plexAccount.resources():
if (resource.product == self.productName and resource.name == self.plexConfig.serverName):
self.plexServer = resource.connect()
try:
self.plexServer.account()
self.isServerOwner = True
except:
pass
self.log("Connected to " + self.productName + " \"" + self.plexConfig.serverName + "\"")
self.plexAlertListener = self.plexServer.startAlertListener(self.onPlexServerAlert)
self.log("Listening for PlaySessionStateNotification alerts from user \"" + self.plexConfig.listenForUser + "\"")
if (self.checkConnectionTimer):
self.checkConnectionTimer.cancel()
self.checkConnectionTimer = None
self.checkConnectionTimer = threading.Timer(self.checkConnectionTimerInterval, self.checkConnection)
self.checkConnectionTimer.start()
connected = True
break
if (not self.plexServer):
self.log(self.productName + " \"" + self.plexConfig.serverName + "\" not found")
break
except Exception as e:
self.log("Failed to connect to Plex: " + str(e))
self.log("Reconnecting in 10 seconds")
time.sleep(10)
def reset(self):
if (self.running):
self.stop()
self.plexAccount, self.plexServer = None, None
if (self.plexAlertListener):
try:
self.plexAlertListener.stop()
except:
pass
self.plexAlertListener = None
if (self.stopTimer):
self.stopTimer.cancel()
self.stopTimer = None
if (self.stopTimer2):
self.stopTimer2.cancel()
self.stopTimer2 = None
if (self.checkConnectionTimer):
self.checkConnectionTimer.cancel()
self.checkConnectionTimer = None
def checkConnection(self):
try:
self.log("Request for clients list to check connection: " + str(self.plexServer.clients()), extra = True)
self.checkConnectionTimer = threading.Timer(self.checkConnectionTimerInterval, self.checkConnection)
self.checkConnectionTimer.start()
except Exception as e:
self.log("Connection to Plex lost: " + str(e))
self.log("Reconnecting")
self.run()
def log(self, text, colour = "", extra = False):
timestamp = datetime.datetime.now().strftime("%I:%M:%S %p")
prefix = "[" + timestamp + "] [" + self.plexConfig.serverName + "/" + self.instanceID + "] "
lock.acquire()
if (extra):
if (self.plexConfig.extraLogging):
print(prefix + colourText(str(text), colour))
else:
print(prefix + colourText(str(text), colour))
lock.release()
def onPlexServerAlert(self, data):
if (not self.plexServer):
return
try:
if (data["type"] == "playing" and "PlaySessionStateNotification" in data):
sessionData = data["PlaySessionStateNotification"][0]
state = sessionData["state"]
sessionKey = int(sessionData["sessionKey"])
ratingKey = int(sessionData["ratingKey"])
viewOffset = int(sessionData["viewOffset"])
self.log("Received Update: " + colourText(sessionData, "yellow").replace("'", "\""), extra = True)
metadata = self.plexServer.fetchItem(ratingKey)
libraryName = metadata.section().title
if (isinstance(self.plexConfig.blacklistedLibraries, list)):
if (libraryName in self.plexConfig.blacklistedLibraries):
self.log("Library \"" + libraryName + "\" is blacklisted, ignoring", "yellow", True)
return
if (isinstance(self.plexConfig.whitelistedLibraries, list)):
if (libraryName not in self.plexConfig.whitelistedLibraries):
self.log("Library \"" + libraryName + "\" is not whitelisted, ignoring", "yellow", True)
return
if (self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey):
if (self.stopTimer2):
self.stopTimer2.cancel()
self.stopTimer2 = None
if (self.lastState == state):
if (self.ignoreCount == self.maximumIgnores):
self.ignoreCount = 0
else:
self.log("Nothing changed, ignoring", "yellow", True)
self.ignoreCount += 1
self.stopTimer2 = threading.Timer(self.stopTimer2Interval, self.stopOnNoUpdate)
self.stopTimer2.start()
return
elif (state == "stopped"):
self.lastState, self.lastSessionKey, self.lastRatingKey = None, None, None
self.stopTimer = threading.Timer(self.stopTimerInterval, self.stop)
self.stopTimer.start()
self.log("Started stopTimer", "yellow", True)
return
elif (state == "stopped"):
self.log("\"stopped\" state update from unknown session key, ignoring", "yellow", True)
return
if (self.isServerOwner):
self.log("Checking Sessions for Session Key " + colourText(sessionKey, "yellow"), extra = True)
plexServerSessions = self.plexServer.sessions()
if (len(plexServerSessions) < 1):
self.log("Empty session list, ignoring", "red", True)
return
for session in plexServerSessions:
self.log(str(session) + ", Session Key: " + colourText(session.sessionKey, "yellow") + ", Users: " + colourText(session.usernames, "yellow").replace("'", "\""), extra = True)
sessionFound = False
if (session.sessionKey == sessionKey):
sessionFound = True
self.log("Session found", "green", True)
if (session.usernames[0].lower() == self.plexConfig.listenForUser):
self.log("Username \"" + session.usernames[0].lower() + "\" matches \"" + self.plexConfig.listenForUser + "\", continuing", "green", True)
break
else:
self.log("Username \"" + session.usernames[0].lower() + "\" doesn't match \"" + self.plexConfig.listenForUser + "\", ignoring", "red", True)
return
if (not sessionFound):
self.log("No matching session found", "red", True)
return
if (self.stopTimer):
self.stopTimer.cancel()
self.stopTimer = None
if (self.stopTimer2):
self.stopTimer2.cancel()
self.stopTimer2 = threading.Timer(self.stopTimer2Interval, self.stopOnNoUpdate)
self.stopTimer2.start()
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
mediaType = metadata.type
if (state != "playing"):
extra = secondsToText(viewOffset / 1000, ":") + "/" + secondsToText(metadata.duration / 1000, ":")
else:
extra = secondsToText(metadata.duration / 1000)
if (mediaType == "movie"):
title = metadata.title + " (" + str(metadata.year) + ")"
extra = extra + " · " + ", ".join([genre.tag for genre in metadata.genres[:3]])
largeText = "Watching a Movie"
elif (mediaType == "episode"):
title = metadata.grandparentTitle
extra = extra + " · S" + str(metadata.parentIndex) + " · E" + str(metadata.index) + " - " + metadata.title
largeText = "Watching a TV Show"
elif (mediaType == "track"):
title = metadata.title
artist = metadata.originalTitle
if (not artist):
artist = metadata.grandparentTitle
extra = artist + " · " + metadata.parentTitle
largeText = "Listening to Music"
else:
self.log("Unsupported media type \"" + mediaType + "\", ignoring", "red", True)
return
activity = {
"details": title,
"state": extra,
"assets": {
"large_text": largeText,
"large_image": "logo",
"small_text": state.capitalize(),
"small_image": state
},
}
if (state == "playing"):
currentTimestamp = int(time.time())
if (self.plexConfig.timeRemaining):
activity["timestamps"] = {"end": round(currentTimestamp + ((metadata.duration - viewOffset) / 1000))}
else:
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}
if (not self.running):
self.start()
if (self.running):
self.send(activity)
else:
self.stop()
except Exception as e:
self.log("onPlexServerAlert Error: " + str(e))
def stopOnNoUpdate(self):
self.log("No updates from session key " + str(self.lastSessionKey) + ", stopping", "red", True)
self.stop()
isLinux = sys.platform in ["linux", "darwin"]
lock = threading.Semaphore(value = 1)
os.system("clear" if isLinux else "cls")
if (len(plexConfigs) == 0):
print("Error: plexConfigs list is empty")
sys.exit()
colours = {
"red": "91",
"green": "92",
"yellow": "93",
"blue": "94",
"magenta": "96",
"cyan": "97"
}
def colourText(text, colour = ""):
prefix = ""
suffix = ""
colour = colour.lower()
if (colour in colours):
prefix = "\033[" + colours[colour] + "m"
suffix = "\033[0m"
return prefix + str(text) + suffix
def secondsToText(seconds, joiner = ""):
seconds = round(seconds)
text = {"h": seconds // 3600, "m": seconds // 60 % 60, "s": seconds % 60}
if (joiner == ""):
text = [str(v) + k for k, v in text.items() if v > 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))

34
main.py Normal file
View file

@ -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")

0
models/__init__.py Normal file
View file

22
models/config.py Normal file
View file

@ -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]

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
plexapi
websocket-client

41
services/ConfigService.py Normal file
View file

@ -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")

View file

@ -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("<ii", op, len(payload)) + payload.encode("utf-8"))
except:
logger.exception("An unexpected error occured during a RPC write operation")
self.disconnect()
def disconnect(self):
logger.info("Disconnecting Discord IPC Pipe")
if (self.pipeWriter):
try:
self.pipeWriter.close()
except:
logger.exception("An unexpected error occured while closing an IPC pipe writer")
self.pipeWriter = None
if (self.pipeReader):
try:
self.loop.run_until_complete(self.pipeReader.read())
except:
logger.exception("An unexpected error occured while closing an IPC pipe reader")
self.pipeReader = None
try:
self.loop.close()
except:
logger.exception("An unexpected error occured while closing an asyncio event loop")
self.connected = False
def sendActivity(self, activity):
logger.info("Activity update: %s", activity)
payload = {
"cmd": "SET_ACTIVITY",
"args": {
"pid": processID,
"activity": activity,
},
"nonce": "{0:.20f}".format(time.time())
}
self.write(1, payload)
self.loop.run_until_complete(self.read())

View file

@ -0,0 +1,231 @@
# type: ignore
from plexapi.myplex import MyPlexAccount
from services import DiscordRpcService
from utils.logs import LoggerWithPrefix
from utils.text import formatSeconds
import hashlib
import threading
import time
class PlexAlertListener:
productName = "Plex Media Server"
stopTimeoutTimerInterval = 5
updateTimeoutTimerInterval = 35
connectionTimeoutTimerInterval = 60
maximumIgnores = 3
useRemainingTime = False
def __init__(self, username, token, serverConfig):
self.username = username
self.token = token
self.serverConfig = serverConfig
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}/{hashlib.md5(str(id(self)).encode('UTF-8')).hexdigest()[:5].upper()}] ")
self.discordRpcService = DiscordRpcService()
self.stopTimeoutTimer = None
self.updateTimeoutTimer = None
self.connectionTimeoutTimer = None
self.reset()
self.connect()
def reset(self):
self.plexAccount = None
self.plexServer = None
self.isServerOwner = False
self.plexAlertListener = None
self.lastState = None
self.lastSessionKey = None
self.lastRatingKey = None
self.ignoreCount = 0
def connect(self):
connected = False
while (not connected):
try:
self.plexAccount = MyPlexAccount(self.username, token = self.token)
self.logger.info("Logged in as Plex User \"%s\"", self.plexAccount.username)
self.plexServer = None
for resource in self.plexAccount.resources():
if (resource.product == self.productName and resource.name == self.serverConfig["name"]):
self.logger.info("Connecting to %s \"%s\"", self.productName, self.serverConfig["name"])
self.plexServer = resource.connect()
try:
self.plexServer.account()
self.isServerOwner = True
except:
pass
self.logger.info("Connected to %s \"%s\"", self.productName, self.serverConfig["name"])
self.plexAlertListener = self.plexServer.startAlertListener(self.handlePlexAlert)
self.logger.info("Listening for alerts from user \"%s\"", self.username)
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
self.connectionTimeoutTimer.start()
connected = True
break
if (not self.plexServer):
self.logger.error("%s \"%s\" not found", self.productName, self.serverConfig["name"])
break
except Exception as e:
self.logger.error("Failed to connect to %s \"%s\": %s", self.productName, self.serverConfig["name"], e)
self.logger.error("Reconnecting in 10 seconds")
time.sleep(10)
def disconnect(self):
self.disconnectRpc()
self.cancelTimers()
if (self.plexAlertListener):
try:
self.plexAlertListener.stop()
except:
pass
self.reset()
self.logger.info("Stopped listening for alerts")
def disconnectRpc(self):
if (self.discordRpcService.connected):
self.discordRpcService.disconnect()
def cancelTimers(self):
if (self.stopTimeoutTimer):
self.stopTimeoutTimer.cancel()
self.stopTimeoutTimer = None
if (self.updateTimeoutTimer):
self.updateTimeoutTimer.cancel()
self.updateTimeoutTimer = None
if (self.connectionTimeoutTimer):
self.connectionTimeoutTimer.cancel()
self.connectionTimeoutTimer = None
def stopTimeout(self):
self.disconnectRpc()
self.cancelTimers()
def updateTimeout(self):
self.logger.debug("No recent updates from session key %s", self.lastSessionKey)
self.stopTimeout()
def connectionTimeout(self):
try:
self.logger.debug("Request for list of clients to check connection: %s", self.plexServer.clients())
except Exception as e:
self.logger.error("Connection to Plex lost: %s", e)
self.disconnect()
self.logger.error("Reconnecting")
self.connect()
else:
self.connectionTimeoutTimer = threading.Timer(self.connectionTimeoutTimerInterval, self.connectionTimeout)
self.connectionTimeoutTimer.start()
def handlePlexAlert(self, data):
try:
if (data["type"] == "playing" and "PlaySessionStateNotification" in data):
sessionData = data["PlaySessionStateNotification"][0]
state = sessionData["state"]
sessionKey = int(sessionData["sessionKey"])
ratingKey = int(sessionData["ratingKey"])
viewOffset = int(sessionData["viewOffset"])
self.logger.debug("Received alert: %s", sessionData)
metadata = self.plexServer.fetchItem(ratingKey)
libraryName = metadata.section().title
if ("blacklistedLibraries" in self.serverConfig and libraryName in self.serverConfig["blacklistedLibraries"]):
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)
return
if (self.lastSessionKey == sessionKey and self.lastRatingKey == ratingKey):
if (self.updateTimeoutTimer):
self.updateTimeoutTimer.cancel()
self.updateTimeoutTimer = None
if (self.lastState == state and self.ignoreCount < self.maximumIgnores):
self.logger.debug("Nothing changed, ignoring")
self.ignoreCount += 1
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
self.updateTimeoutTimer.start()
return
else:
self.ignoreCount = 0
if (state == "stopped"):
self.lastState, self.lastSessionKey, self.lastRatingKey = None, None, None
self.stopTimeout()
self.stopTimeoutTimer = threading.Timer(self.stopTimeoutTimerInterval, self.stopTimeout)
self.stopTimeoutTimer.start()
self.logger.debug("Started stopTimeoutTimer")
return
elif (state == "stopped"):
self.logger.debug("Received \"stopped\" state alert from unknown session key, ignoring")
return
if (self.isServerOwner):
self.logger.debug("Searching sessions for session key %s", sessionKey)
plexServerSessions = self.plexServer.sessions()
if (len(plexServerSessions) < 1):
self.logger.debug("Empty session list, ignoring")
return
for session in plexServerSessions:
self.logger.debug("%s, Session Key: %s, Usernames: %s", session, session.sessionKey, session.usernames)
if (session.sessionKey == sessionKey):
self.logger.debug("Session found")
sessionUsername = session.usernames[0].lower()
if (sessionUsername == self.username):
self.logger.debug("Username \"%s\" matches \"%s\", continuing", sessionUsername, self.username)
break
else:
self.logger.debug("Username \"%s\" doesn't match \"%s\", ignoring", sessionUsername, self.username)
return
else:
self.logger.debug("No matching session found, ignoring")
return
if (self.stopTimeoutTimer):
self.stopTimeoutTimer.cancel()
self.stopTimeoutTimer = None
if (self.updateTimeoutTimer):
self.updateTimeoutTimer.cancel()
self.updateTimeoutTimer = threading.Timer(self.updateTimeoutTimerInterval, self.updateTimeout)
self.updateTimeoutTimer.start()
self.lastState, self.lastSessionKey, self.lastRatingKey = state, sessionKey, ratingKey
mediaType = metadata.type
if (state != "playing"):
stateText = f"{formatSeconds(viewOffset / 1000, ':')}/{formatSeconds(metadata.duration / 1000, ':')}"
else:
stateText = formatSeconds(metadata.duration / 1000)
if (mediaType == "movie"):
title = f"{metadata.title} ({metadata.year})"
stateText += f" · {', '.join(genre.tag for genre in metadata.genres[:3])}"
largeText = "Watching a movie"
elif (mediaType == "episode"):
title = metadata.grandparentTitle
stateText += f" · S{metadata.parentIndex:02}E{metadata.index:02} - {metadata.title}"
largeText = "Watching a TV show"
elif (mediaType == "track"):
print(metadata)
title = metadata.title
artist = metadata.originalTitle
if (not artist):
artist = metadata.grandparentTitle
stateText = f"{artist} - {metadata.parentTitle}"
largeText = "Listening to music"
else:
self.logger.debug("Unsupported media type \"%s\", ignoring", mediaType)
return
activity = {
"details": title[:128],
"state": stateText[:128],
"assets": {
"large_text": largeText,
"large_image": "logo",
"small_text": state.capitalize(),
"small_image": state
},
}
if (state == "playing"):
currentTimestamp = int(time.time())
if (self.useRemainingTime):
activity["timestamps"] = {"end": round(currentTimestamp + ((metadata.duration - viewOffset) / 1000))}
else:
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}
if (not self.discordRpcService.connected):
self.discordRpcService.connect()
if (self.discordRpcService.connected):
self.discordRpcService.sendActivity(activity)
except:
self.logger.exception("An unexpected error occured in the alert handler")

3
services/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .ConfigService import ConfigService as ConfigService
from .DiscordRpcService import DiscordRpcService as DiscordRpcService
from .PlexAlertListener import PlexAlertListener as PlexAlertListener

0
store/__init__.py Normal file
View file

5
store/constants.py Normal file
View file

@ -0,0 +1,5 @@
import sys
import os
isUnix = sys.platform in ["linux", "darwin"]
processID = os.getpid()

0
utils/__init__.py Normal file
View file

28
utils/logs.py Normal file
View file

@ -0,0 +1,28 @@
from typing import Any
import logging
logger = logging.getLogger("discord-rich-presence-plex")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt = "%d-%m-%Y %I:%M:%S %p"))
logger.addHandler(handler)
class LoggerWithPrefix:
def __init__(self, prefix: str) -> 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)

8
utils/text.py Normal file
View file

@ -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())