mirror of
https://github.com/phin05/discord-rich-presence-plex
synced 2024-11-22 01:23:02 +00:00
Minor refactor and improvements
This commit is contained in:
parent
5e4a56ada4
commit
2d2bbbc55f
8 changed files with 76 additions and 60 deletions
|
@ -1,11 +1,10 @@
|
|||
FROM python:3.10-alpine
|
||||
ARG USERNAME=app
|
||||
ARG USER_UID=10000
|
||||
ARG USER_GID=$USER_UID
|
||||
RUN addgroup -g $USER_GID $USERNAME && adduser -u $USER_UID -G $USERNAME -D $USERNAME
|
||||
ARG USER_UID_GID=10000
|
||||
RUN addgroup -g $USER_UID_GID $USERNAME && adduser -u $USER_UID_GID -G $USERNAME -D $USERNAME
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt --no-cache-dir
|
||||
RUN pip install -U -r requirements.txt --no-cache-dir
|
||||
COPY . .
|
||||
ENV DRPP_CONTAINER_DEMOTION_UID=$USER_UID
|
||||
ENV DRPP_CONTAINER_DEMOTION_UID_GID=$USER_UID_GID
|
||||
CMD ["python", "main.py"]
|
||||
|
|
|
@ -23,7 +23,7 @@ The script must be running on the same machine as your Discord client.
|
|||
|
||||
## Configuration
|
||||
|
||||
A directory named `data` is used for storing the configuration file.
|
||||
The config file is stored in a directory named `data`.
|
||||
|
||||
### Supported Formats
|
||||
|
||||
|
@ -164,7 +164,7 @@ The "Display current activity as a status message" setting must be enabled in Di
|
|||
|
||||
## Configuration - Environment Variables
|
||||
|
||||
* `PLEX_SERVER_NAME` - Name of the Plex Media Server you wish to connect to. Used only during the initial setup (when there are no users in the config) for adding a server to the config after authentication. If this isn't set, in interactive environments, the user is prompted for an input, and in non-interactive environments, "ServerName" is used as a placeholder, which can later be changed by editing the configuration file and restarting the script.
|
||||
* `PLEX_SERVER_NAME` - Name of the Plex Media Server you wish to connect to. Used only during the initial setup (when there are no users in the config) for adding a server to the config after authentication. If this isn't set, in interactive environments, the user is prompted for an input, and in non-interactive environments, "ServerName" is used as a placeholder, which can later be changed by editing the config file and restarting the script.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
|
@ -174,7 +174,7 @@ The "Display current activity as a status message" setting must be enabled in Di
|
|||
|
||||
### Volumes
|
||||
|
||||
Mount a directory for persistent data (configuration file, cache file and log file) at `/app/data`.
|
||||
Mount a directory for persistent data (config file, cache file and log file) at `/app/data`.
|
||||
|
||||
The directory where Discord stores its inter-process communication Unix socket file needs to be mounted into the container at `/run/app`. The path for this would be the first non-null value from the values of the following environment variables: ([source](https://github.com/discord/discord-rpc/blob/963aa9f3e5ce81a4682c6ca3d136cddda614db33/src/connection_unix.cpp#L29C33-L29C33))
|
||||
|
||||
|
|
|
@ -15,4 +15,4 @@ logFilePath = os.path.join(dataDirectoryPath, "console.log")
|
|||
isUnix = sys.platform in ["linux", "darwin"]
|
||||
processID = os.getpid()
|
||||
isInteractive = sys.stdin and sys.stdin.isatty()
|
||||
containerDemotionUid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID", "")
|
||||
containerDemotionUidGid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID_GID", "")
|
||||
|
|
|
@ -33,18 +33,18 @@ configFileType = ""
|
|||
configFilePath = ""
|
||||
|
||||
def loadConfig() -> None:
|
||||
global configFileType, configFileExtension, configFilePath
|
||||
for fileExtension, fileType in supportedConfigFileExtensions.items():
|
||||
filePath = f"{configFilePathRoot}.{fileExtension}"
|
||||
if os.path.isfile(filePath):
|
||||
global configFileExtension, configFileType, configFilePath
|
||||
doesFileExist = False
|
||||
for i, (fileExtension, fileType) in enumerate(supportedConfigFileExtensions.items()):
|
||||
doesFileExist = os.path.isfile(f"{configFilePathRoot}.{fileExtension}")
|
||||
isFirstItem = i == 0
|
||||
if doesFileExist or isFirstItem:
|
||||
configFileExtension = fileExtension
|
||||
configFileType = fileType
|
||||
configFilePath = f"{configFilePathRoot}.{configFileExtension}"
|
||||
if doesFileExist:
|
||||
break
|
||||
else:
|
||||
configFileExtension, configFileType = list(supportedConfigFileExtensions.items())[0]
|
||||
filePath = f"{configFilePathRoot}.{configFileExtension}"
|
||||
configFilePath = filePath
|
||||
if os.path.isfile(configFilePath):
|
||||
if doesFileExist:
|
||||
try:
|
||||
with open(configFilePath, "r", encoding = "UTF-8") as configFile:
|
||||
if configFileType == "yaml":
|
||||
|
@ -53,7 +53,7 @@ def loadConfig() -> None:
|
|||
loadedConfig = json.load(configFile) or {}
|
||||
except:
|
||||
os.rename(configFilePath, f"{configFilePathRoot}-{time.time():.0f}.{configFileExtension}")
|
||||
logger.exception("Failed to parse the application's config file. A new one will be created.")
|
||||
logger.exception("Failed to parse the config file. A new one will be created.")
|
||||
else:
|
||||
copyDict(loadedConfig, config)
|
||||
saveConfig()
|
||||
|
@ -71,4 +71,4 @@ def saveConfig() -> None:
|
|||
json.dump(config, configFile, indent = "\t")
|
||||
configFile.write("\n")
|
||||
except:
|
||||
logger.exception("Failed to write to the application's config file.")
|
||||
logger.exception("Failed to write to the config file")
|
||||
|
|
20
core/plex.py
20
core/plex.py
|
@ -3,6 +3,7 @@
|
|||
from .config import config
|
||||
from .discord import DiscordIpcService
|
||||
from .imgur import uploadToImgur
|
||||
from config.constants import name, plexClientID
|
||||
from plexapi.alert import AlertListener
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.media import Genre, GuidTag
|
||||
|
@ -14,8 +15,24 @@ from utils.text import formatSeconds
|
|||
import models.config
|
||||
import models.discord
|
||||
import models.plex
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
def initiateAuth() -> tuple[str, str, str]:
|
||||
response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = {
|
||||
"X-Plex-Product": name,
|
||||
"X-Plex-Client-Identifier": plexClientID,
|
||||
}).json()
|
||||
authUrl = f"https://app.plex.tv/auth#?clientID={plexClientID}&code={response['code']}&context%%5Bdevice%%5D%%5Bproduct%%5D={urllib.parse.quote(name)}"
|
||||
return response["id"], response["code"], authUrl
|
||||
|
||||
def getAuthToken(id: str, code: str) -> Optional[str]:
|
||||
response = requests.get(f"https://plex.tv/api/v2/pins/{id}.json?code={code}", headers = {
|
||||
"X-Plex-Client-Identifier": plexClientID,
|
||||
}).json()
|
||||
return response["authToken"]
|
||||
|
||||
class PlexAlertListener(threading.Thread):
|
||||
|
||||
|
@ -67,8 +84,7 @@ class PlexAlertListener(threading.Thread):
|
|||
connected = True
|
||||
break
|
||||
if not self.server:
|
||||
self.logger.error("%s \"%s\" not found", self.productName, self.serverConfig["name"])
|
||||
break
|
||||
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("Reconnecting in 10 seconds")
|
||||
|
|
54
main.py
54
main.py
|
@ -1,37 +1,42 @@
|
|||
from config.constants import isUnix, containerDemotionUid
|
||||
from config.constants import isUnix, containerDemotionUidGid
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if isUnix and containerDemotionUid:
|
||||
uid = int(containerDemotionUid)
|
||||
os.system(f"chown -R {uid}:{uid} /app")
|
||||
os.setuid(uid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
|
||||
if isUnix and containerDemotionUidGid:
|
||||
uidGid = int(containerDemotionUidGid)
|
||||
os.system(f"chown -R {uidGid}:{uidGid} {os.path.dirname(os.path.realpath(__file__))}")
|
||||
os.setgid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
|
||||
os.setuid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
|
||||
else:
|
||||
try:
|
||||
import subprocess
|
||||
def parsePipPackages(packagesStr: str) -> dict[str, str]:
|
||||
return { packageSplit[0]: packageSplit[1] for packageSplit in [package.split("==") for package in packagesStr.splitlines()] }
|
||||
pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], capture_output = True, text = True)
|
||||
return { packageSplit[0]: packageSplit[1] if len(packageSplit) > 1 else "" for packageSplit in [package.split("==") for package in packagesStr.splitlines()] }
|
||||
pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], stdout = subprocess.PIPE, text = True, check = True)
|
||||
installedPackages = parsePipPackages(pipFreezeResult.stdout)
|
||||
with open("requirements.txt", "r", encoding = "UTF-8") as requirementsFile:
|
||||
requiredPackages = parsePipPackages(requirementsFile.read())
|
||||
for packageName, packageVersion in requiredPackages.items():
|
||||
if packageName not in installedPackages:
|
||||
print(f"Required package '{packageName}' not found, installing...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", f"{packageName}=={packageVersion}"], check = True)
|
||||
package = f"{packageName}{f'=={packageVersion}' if packageVersion else ''}"
|
||||
print(f"Installing missing dependency: {package}")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "-U", package], check = True)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exception(e)
|
||||
print("An unexpected error occured during automatic installation of dependencies. Install them manually by running the following command: python -m pip install -U -r requirements.txt")
|
||||
|
||||
from config.constants import dataDirectoryPath, logFilePath, name, plexClientID, version, isInteractive
|
||||
from config.constants import dataDirectoryPath, logFilePath, name, version, isInteractive
|
||||
from core.config import config, loadConfig, saveConfig
|
||||
from core.discord import DiscordIpcService
|
||||
from core.plex import PlexAlertListener
|
||||
from core.plex import PlexAlertListener, initiateAuth, getAuthToken
|
||||
from typing import Optional
|
||||
from utils.cache import loadCache
|
||||
from utils.logging import formatter, logger
|
||||
from utils.text import formatSeconds
|
||||
import logging
|
||||
import models.config
|
||||
import requests
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
def main() -> None:
|
||||
if not os.path.exists(dataDirectoryPath):
|
||||
|
@ -50,7 +55,7 @@ def main() -> None:
|
|||
loadCache()
|
||||
if not config["users"]:
|
||||
logger.info("No users found in the config file")
|
||||
user = authUser()
|
||||
user = authNewUser()
|
||||
if not user:
|
||||
exit()
|
||||
config["users"].append(user)
|
||||
|
@ -69,25 +74,20 @@ def main() -> None:
|
|||
for plexAlertListener in plexAlertListeners:
|
||||
plexAlertListener.disconnect()
|
||||
|
||||
def authUser() -> Optional[models.config.User]:
|
||||
response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = {
|
||||
"X-Plex-Product": name,
|
||||
"X-Plex-Client-Identifier": plexClientID,
|
||||
}).json()
|
||||
def authNewUser() -> Optional[models.config.User]:
|
||||
id, code, url = initiateAuth()
|
||||
logger.info("Open the below URL in your web browser and sign in:")
|
||||
logger.info("https://app.plex.tv/auth#?clientID=%s&code=%s&context%%5Bdevice%%5D%%5Bproduct%%5D=%s", plexClientID, response["code"], urllib.parse.quote(name))
|
||||
logger.info(url)
|
||||
time.sleep(5)
|
||||
for i in range(35):
|
||||
logger.info(f"Checking whether authentication is successful... ({formatSeconds((i + 1) * 5)})")
|
||||
authCheckResponse = requests.get(f"https://plex.tv/api/v2/pins/{response['id']}.json?code={response['code']}", headers = {
|
||||
"X-Plex-Client-Identifier": plexClientID,
|
||||
}).json()
|
||||
if authCheckResponse["authToken"]:
|
||||
logger.info(f"Checking whether authentication is successful ({formatSeconds((i + 1) * 5)})")
|
||||
authToken = getAuthToken(id, code)
|
||||
if authToken:
|
||||
logger.info("Authentication successful")
|
||||
serverName = os.environ.get("PLEX_SERVER_NAME")
|
||||
if not serverName:
|
||||
serverName = input("Enter the name of the Plex Media Server you wish to connect to: ") if isInteractive else "ServerName"
|
||||
return { "token": authCheckResponse["authToken"], "servers": [{ "name": serverName }] }
|
||||
return { "token": authToken, "servers": [{ "name": serverName }] }
|
||||
time.sleep(5)
|
||||
else:
|
||||
logger.info(f"Authentication timed out ({formatSeconds(180)})")
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
PlexAPI==4.10.1
|
||||
requests==2.26.0
|
||||
requests==2.31.0
|
||||
websocket-client==1.3.2
|
||||
PyYAML==6.0.1
|
||||
|
|
|
@ -14,8 +14,9 @@ def loadCache() -> None:
|
|||
with open(cacheFilePath, "r", encoding = "UTF-8") as cacheFile:
|
||||
cache.update(json.load(cacheFile))
|
||||
except:
|
||||
os.rename(cacheFilePath, cacheFilePath.replace(".json", f"-{time.time():.0f}.json"))
|
||||
logger.exception("Failed to parse the application's cache file. A new one will be created.")
|
||||
root, ext = os.path.splitext(cacheFilePath)
|
||||
os.rename(cacheFilePath, f"{root}-{time.time():.0f}.{ext}")
|
||||
logger.exception("Failed to parse the cache file. A new one will be created.")
|
||||
|
||||
def getCacheKey(key: str) -> Any:
|
||||
return cache.get(key)
|
||||
|
@ -26,4 +27,4 @@ def setCacheKey(key: str, value: Any) -> None:
|
|||
with open(cacheFilePath, "w", encoding = "UTF-8") as cacheFile:
|
||||
json.dump(cache, cacheFile, separators = (",", ":"))
|
||||
except:
|
||||
logger.exception("Failed to write to the application's cache file.")
|
||||
logger.exception("Failed to write to the cache file")
|
||||
|
|
Loading…
Reference in a new issue