Ability to display posters in Rich Presence

More refactoring
This commit is contained in:
Phin 2022-05-12 12:44:23 +05:30
parent 7ee73b8dd6
commit a54521ec9e
15 changed files with 170 additions and 71 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
config.json
cache.json

View file

@ -2,7 +2,7 @@
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).
Current Version: 2.1.1
Current Version: 2.2.0
## Getting Started
@ -25,6 +25,9 @@ The script must be running on the same machine as your Discord client.
* `debug` (boolean, default: `true`) - Outputs additional debug-helpful information to the console if enabled.
* `display`
* `useRemainingTime` (boolean, default: `false`) - Displays your media's remaining time instead of elapsed time in your Rich Presence if enabled.
* `posters`
* `enabled` (boolean, default: `false`)
* `imgurClientID` (string, default: `""`)
* `users` (list)
* `token` (string) - An access token associated with your Plex account. ([X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token), [Authenticating with Plex](https://forums.plex.tv/t/authenticating-with-plex/609370))
* `servers` (list)
@ -33,6 +36,12 @@ The script must be running on the same machine as your Discord client.
* `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.
### Obtaining an Imgur client ID
1. Go to Imgur's [application registration page](https://api.imgur.com/oauth2/addclient)
2. Enter any name for the application and pick OAuth2 without a callback URL as the authorisation type
3. Submit the form to obtain your application's client ID
### Example
```json
@ -41,7 +50,11 @@ The script must be running on the same machine as your Discord client.
"debug": true
},
"display": {
"useRemainingTime": false
"useRemainingTime": false,
"posters": {
"enabled": true,
"imgurClientID": "9e9sf637S8bRp4z"
}
},
"users": [
{

17
main.py
View file

@ -1,6 +1,8 @@
from services import ConfigService, PlexAlertListener
from services import PlexAlertListener
from services.cache import loadCache
from services.config import config, loadConfig, saveConfig
from store.constants import isUnix, name, plexClientID, version
from utils.logs import logger
from utils.logging import logger
import logging
import os
import requests
@ -9,12 +11,11 @@ import urllib.parse
os.system("clear" if isUnix else "cls")
logger.info("%s - v%s", name, version)
configService = ConfigService("config.json")
config = configService.config
loadConfig()
loadCache()
if config["logging"]["debug"]:
logger.setLevel(logging.DEBUG)
PlexAlertListener.useRemainingTime = config["display"]["useRemainingTime"]
if len(config["users"]) == 0:
logger.info("No users found in the config file. Initiating authentication flow.")
response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = {
@ -33,7 +34,7 @@ if len(config["users"]) == 0:
logger.info("Authentication successful.")
serverName = input("Enter the name of the Plex Media Server you wish to connect to: ")
config["users"].append({ "token": authCheckResponse["authToken"], "servers": [{ "name": serverName }] })
configService.saveConfig()
saveConfig()
break
time.sleep(5)
else:
@ -48,7 +49,7 @@ try:
while True:
userInput = input()
if userInput in ["exit", "quit"]:
break
raise KeyboardInterrupt
except KeyboardInterrupt:
for plexAlertListener in plexAlertListeners:
plexAlertListener.disconnect()

View file

@ -3,8 +3,13 @@ from typing import TypedDict
class Logging(TypedDict):
debug: bool
class Posters(TypedDict):
enabled: bool
imgurClientID: str
class Display(TypedDict):
useRemainingTime: bool
posters: Posters
class Server(TypedDict, total = False):
name: str

12
models/imgur.py Normal file
View file

@ -0,0 +1,12 @@
from typing import TypedDict
class ImgurResponse(TypedDict):
success: bool
status: int
class ImgurUploadResponseData(TypedDict):
error: str
link: str
class ImgurUploadResponse(ImgurResponse):
data: ImgurUploadResponseData

View file

@ -1,41 +0,0 @@
from models.config import Config
from utils.logs import logger
import json
import os
import time
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"-{time.time():.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

@ -1,7 +1,7 @@
# type: ignore
# type: ignore
from store.constants import isUnix, processID
from utils.logs import logger
from store.constants import discordClientID, isUnix, processID
from utils.logging import logger
import asyncio
import json
import os
@ -10,7 +10,6 @@ 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):
@ -34,7 +33,7 @@ class DiscordRpcService:
else:
self.pipeReader = asyncio.StreamReader()
self.pipeWriter, _ = await self.loop.create_pipe_connection(lambda: asyncio.StreamReaderProtocol(self.pipeReader), self.ipcPipe)
self.write(0, { "v": 1, "client_id": self.clientID })
self.write(0, { "v": 1, "client_id": discordClientID })
if await self.read():
self.connected = True
except:

View file

@ -1,23 +1,27 @@
# type: ignore
from .DiscordRpcService import DiscordRpcService
from .cache import getKey, setKey
from .config import config
from .imgur import uploadImage
from plexapi.alert import AlertListener
from plexapi.myplex import MyPlexAccount
from services import DiscordRpcService
from utils.logs import LoggerWithPrefix
from utils.logging import LoggerWithPrefix
from utils.text import formatSeconds
import hashlib
import threading
import time
class PlexAlertListener:
class PlexAlertListener(threading.Thread):
productName = "Plex Media Server"
updateTimeoutTimerInterval = 30
connectionTimeoutTimerInterval = 60
maximumIgnores = 2
useRemainingTime = False
def __init__(self, token, serverConfig):
super().__init__()
self.daemon = True
self.token = token
self.serverConfig = serverConfig
self.logger = LoggerWithPrefix(f"[{self.serverConfig['name']}/{hashlib.md5(str(id(self)).encode('UTF-8')).hexdigest()[:5].upper()}] ")
@ -25,7 +29,7 @@ class PlexAlertListener:
self.updateTimeoutTimer = None
self.connectionTimeoutTimer = None
self.reset()
self.connect()
self.start()
def reset(self):
self.plexAccount = None
@ -38,7 +42,7 @@ class PlexAlertListener:
self.lastRatingKey = 0
self.ignoreCount = 0
def connect(self):
def run(self):
connected = False
while not connected:
try:
@ -85,7 +89,7 @@ class PlexAlertListener:
self.logger.error("Connection to Plex lost: %s", exception)
self.disconnect()
self.logger.error("Reconnecting")
self.connect()
self.run()
def cancelTimers(self):
if self.updateTimeoutTimer:
@ -180,12 +184,12 @@ class PlexAlertListener:
if len(item.genres) > 0:
stateText += f" · {', '.join(genre.tag for genre in item.genres[:3])}"
largeText = "Watching a movie"
# self.logger.debug("Poster: %s", item.thumbUrl)
plexThumb = item.thumb
elif mediaType == "episode":
title = item.grandparentTitle
stateText += f" · S{item.parentIndex:02}E{item.index:02} - {item.title}"
largeText = "Watching a TV show"
# self.logger.debug("Poster: %s", self.plexServer.url(item.grandparentThumb, True))
plexThumb = item.grandparentThumb
elif mediaType == "track":
title = item.title
artist = item.originalTitle
@ -193,23 +197,29 @@ class PlexAlertListener:
artist = item.grandparentTitle
stateText = f"{artist} - {item.parentTitle}"
largeText = "Listening to music"
# self.logger.debug("Album Art: %s", item.thumbUrl)
plexThumb = item.thumb
else:
self.logger.debug("Unsupported media type \"%s\", ignoring", mediaType)
return
thumbUrl = None
if config["display"]["posters"]["enabled"]:
if not (thumbUrl := getKey(plexThumb)):
self.logger.debug("Uploading image")
thumbUrl = uploadImage(self.plexServer.url(plexThumb, True))
setKey(plexThumb, thumbUrl)
activity = {
"details": title[:128],
"state": stateText[:128],
"assets": {
"large_text": largeText,
"large_image": "logo",
"large_image": thumbUrl or "logo",
"small_text": state.capitalize(),
"small_image": state,
},
}
if state == "playing":
currentTimestamp = int(time.time())
if self.useRemainingTime:
if config["display"]["useRemainingTime"]:
activity["timestamps"] = {"end": round(currentTimestamp + ((item.duration - viewOffset) / 1000))}
else:
activity["timestamps"] = {"start": round(currentTimestamp - (viewOffset / 1000))}

View file

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

28
services/cache.py Normal file
View file

@ -0,0 +1,28 @@
from typing import Any
from store.constants import cacheFilePath
from utils.logging import logger
import json
import os
cache: dict[str, Any] = {}
def loadCache() -> None:
global cache
if os.path.isfile(cacheFilePath):
try:
with open(cacheFilePath, "r", encoding = "UTF-8") as cacheFile:
cache = json.load(cacheFile)
except:
logger.exception("Failed to parse the application's cache file.")
def getKey(key: str) -> Any:
return cache.get(key)
def setKey(key: str, value: Any) -> None:
cache[key] = value
try:
with open(cacheFilePath, "w", encoding = "UTF-8") as cacheFile:
json.dump(cache, cacheFile, indent = "\t")
cacheFile.write("\n")
except:
logger.exception("Failed to write to the application's cache file.")

40
services/config.py Normal file
View file

@ -0,0 +1,40 @@
from models.config import Config
from store.constants import configFilePath
from utils.logging import logger
from utils.dict import merge
import json
import os
import time
config: Config = {
"logging": {
"debug": True,
},
"display": {
"useRemainingTime": False,
"posters": {
"enabled": False,
"imgurClientID": "",
},
},
"users": [],
}
def loadConfig() -> None:
if os.path.isfile(configFilePath):
try:
with open(configFilePath, "r", encoding = "UTF-8") as configFile:
loadedConfig = json.load(configFile)
merge(loadedConfig, config)
except:
os.rename(configFilePath, configFilePath.replace(".json", f"-{time.time():.0f}.json"))
logger.exception("Failed to parse the application's config file. A new one will be created.")
saveConfig()
def saveConfig() -> None:
try:
with open(configFilePath, "w", encoding = "UTF-8") as configFile:
json.dump(config, configFile, indent = "\t")
configFile.write("\n")
except:
logger.exception("Failed to write to the application's config file.")

19
services/imgur.py Normal file
View file

@ -0,0 +1,19 @@
from models.imgur import ImgurUploadResponse
from services.config import config
from typing import Optional
from utils.logging import logger
import requests
def uploadImage(url: str) -> Optional[str]:
try:
data: ImgurUploadResponse = requests.post(
"https://api.imgur.com/3/image",
headers = { "Authorization": f"Client-ID {config['display']['posters']['imgurClientID']}" },
files = { "image": requests.get(url).content }
).json()
if not data["success"]:
raise Exception(data["data"]["error"])
return data["data"]["link"]
except:
logger.exception("An unexpected error occured while uploading an image")
return None

View file

@ -1,8 +1,13 @@
import sys
import os
import sys
name = "Discord Rich Presence for Plex"
version = "2.1.1"
version = "2.2.0"
plexClientID = "discord-rich-presence-plex"
discordClientID = "413407336082833418"
configFilePath = "config.json"
cacheFilePath = "cache.json"
isUnix = sys.platform in ["linux", "darwin"]
processID = os.getpid()

8
utils/dict.py Normal file
View file

@ -0,0 +1,8 @@
from typing import Any
def merge(source: Any, target: Any) -> None:
for key, value in source.items():
if isinstance(value, dict):
merge(value, target.setdefault(key, {}))
else:
target[key] = source[key]