Minor refactor and improvements

This commit is contained in:
Phin 2023-11-05 10:54:45 +05:30
parent 5e4a56ada4
commit 2d2bbbc55f
8 changed files with 76 additions and 60 deletions

View file

@ -1,11 +1,10 @@
FROM python:3.10-alpine FROM python:3.10-alpine
ARG USERNAME=app ARG USERNAME=app
ARG USER_UID=10000 ARG USER_UID_GID=10000
ARG USER_GID=$USER_UID RUN addgroup -g $USER_UID_GID $USERNAME && adduser -u $USER_UID_GID -G $USERNAME -D $USERNAME
RUN addgroup -g $USER_GID $USERNAME && adduser -u $USER_UID -G $USERNAME -D $USERNAME
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir RUN pip install -U -r requirements.txt --no-cache-dir
COPY . . COPY . .
ENV DRPP_CONTAINER_DEMOTION_UID=$USER_UID ENV DRPP_CONTAINER_DEMOTION_UID_GID=$USER_UID_GID
CMD ["python", "main.py"] CMD ["python", "main.py"]

View file

@ -23,7 +23,7 @@ The script must be running on the same machine as your Discord client.
## Configuration ## Configuration
A directory named `data` is used for storing the configuration file. The config file is stored in a directory named `data`.
### Supported Formats ### Supported Formats
@ -164,7 +164,7 @@ The "Display current activity as a status message" setting must be enabled in Di
## Configuration - Environment Variables ## 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 ## Run with Docker
@ -174,7 +174,7 @@ The "Display current activity as a status message" setting must be enabled in Di
### Volumes ### 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)) 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))

View file

@ -15,4 +15,4 @@ logFilePath = os.path.join(dataDirectoryPath, "console.log")
isUnix = sys.platform in ["linux", "darwin"] isUnix = sys.platform in ["linux", "darwin"]
processID = os.getpid() processID = os.getpid()
isInteractive = sys.stdin and sys.stdin.isatty() isInteractive = sys.stdin and sys.stdin.isatty()
containerDemotionUid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID", "") containerDemotionUidGid = os.environ.get("DRPP_CONTAINER_DEMOTION_UID_GID", "")

View file

@ -33,18 +33,18 @@ configFileType = ""
configFilePath = "" configFilePath = ""
def loadConfig() -> None: def loadConfig() -> None:
global configFileType, configFileExtension, configFilePath global configFileExtension, configFileType, configFilePath
for fileExtension, fileType in supportedConfigFileExtensions.items(): doesFileExist = False
filePath = f"{configFilePathRoot}.{fileExtension}" for i, (fileExtension, fileType) in enumerate(supportedConfigFileExtensions.items()):
if os.path.isfile(filePath): doesFileExist = os.path.isfile(f"{configFilePathRoot}.{fileExtension}")
isFirstItem = i == 0
if doesFileExist or isFirstItem:
configFileExtension = fileExtension configFileExtension = fileExtension
configFileType = fileType configFileType = fileType
break configFilePath = f"{configFilePathRoot}.{configFileExtension}"
else: if doesFileExist:
configFileExtension, configFileType = list(supportedConfigFileExtensions.items())[0] break
filePath = f"{configFilePathRoot}.{configFileExtension}" if doesFileExist:
configFilePath = filePath
if os.path.isfile(configFilePath):
try: try:
with open(configFilePath, "r", encoding = "UTF-8") as configFile: with open(configFilePath, "r", encoding = "UTF-8") as configFile:
if configFileType == "yaml": if configFileType == "yaml":
@ -53,7 +53,7 @@ def loadConfig() -> None:
loadedConfig = json.load(configFile) or {} loadedConfig = json.load(configFile) or {}
except: except:
os.rename(configFilePath, f"{configFilePathRoot}-{time.time():.0f}.{configFileExtension}") 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: else:
copyDict(loadedConfig, config) copyDict(loadedConfig, config)
saveConfig() saveConfig()
@ -71,4 +71,4 @@ def saveConfig() -> None:
json.dump(config, configFile, indent = "\t") json.dump(config, configFile, indent = "\t")
configFile.write("\n") configFile.write("\n")
except: except:
logger.exception("Failed to write to the application's config file.") logger.exception("Failed to write to the config file")

View file

@ -3,6 +3,7 @@
from .config import config from .config import config
from .discord import DiscordIpcService from .discord import DiscordIpcService
from .imgur import uploadToImgur from .imgur import uploadToImgur
from config.constants import name, plexClientID
from plexapi.alert import AlertListener from plexapi.alert import AlertListener
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.media import Genre, GuidTag from plexapi.media import Genre, GuidTag
@ -14,8 +15,24 @@ from utils.text import formatSeconds
import models.config import models.config
import models.discord import models.discord
import models.plex import models.plex
import requests
import threading import threading
import time 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): class PlexAlertListener(threading.Thread):
@ -67,8 +84,7 @@ class PlexAlertListener(threading.Thread):
connected = True connected = True
break break
if not self.server: if not self.server:
self.logger.error("%s \"%s\" not found", self.productName, self.serverConfig["name"]) raise Exception("Server not found")
break
except Exception as e: 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") self.logger.error("Reconnecting in 10 seconds")

66
main.py
View file

@ -1,37 +1,42 @@
from config.constants import isUnix, containerDemotionUid from config.constants import isUnix, containerDemotionUidGid
import os import os
import subprocess
import sys import sys
if isUnix and containerDemotionUid: if isUnix and containerDemotionUidGid:
uid = int(containerDemotionUid) uidGid = int(containerDemotionUidGid)
os.system(f"chown -R {uid}:{uid} /app") os.system(f"chown -R {uidGid}:{uidGid} {os.path.dirname(os.path.realpath(__file__))}")
os.setuid(uid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType] os.setgid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
os.setuid(uidGid) # pyright: ignore[reportGeneralTypeIssues,reportUnknownMemberType]
else: else:
def parsePipPackages(packagesStr: str) -> dict[str, str]: try:
return { packageSplit[0]: packageSplit[1] for packageSplit in [package.split("==") for package in packagesStr.splitlines()] } import subprocess
pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], capture_output = True, text = True) def parsePipPackages(packagesStr: str) -> dict[str, str]:
installedPackages = parsePipPackages(pipFreezeResult.stdout) return { packageSplit[0]: packageSplit[1] if len(packageSplit) > 1 else "" for packageSplit in [package.split("==") for package in packagesStr.splitlines()] }
with open("requirements.txt", "r", encoding = "UTF-8") as requirementsFile: pipFreezeResult = subprocess.run([sys.executable, "-m", "pip", "freeze"], stdout = subprocess.PIPE, text = True, check = True)
requiredPackages = parsePipPackages(requirementsFile.read()) installedPackages = parsePipPackages(pipFreezeResult.stdout)
for packageName, packageVersion in requiredPackages.items(): with open("requirements.txt", "r", encoding = "UTF-8") as requirementsFile:
if packageName not in installedPackages: requiredPackages = parsePipPackages(requirementsFile.read())
print(f"Required package '{packageName}' not found, installing...") for packageName, packageVersion in requiredPackages.items():
subprocess.run([sys.executable, "-m", "pip", "install", f"{packageName}=={packageVersion}"], check = True) if packageName not in installedPackages:
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.config import config, loadConfig, saveConfig
from core.discord import DiscordIpcService from core.discord import DiscordIpcService
from core.plex import PlexAlertListener from core.plex import PlexAlertListener, initiateAuth, getAuthToken
from typing import Optional from typing import Optional
from utils.cache import loadCache from utils.cache import loadCache
from utils.logging import formatter, logger from utils.logging import formatter, logger
from utils.text import formatSeconds from utils.text import formatSeconds
import logging import logging
import models.config import models.config
import requests
import time import time
import urllib.parse
def main() -> None: def main() -> None:
if not os.path.exists(dataDirectoryPath): if not os.path.exists(dataDirectoryPath):
@ -50,7 +55,7 @@ def main() -> None:
loadCache() loadCache()
if not config["users"]: if not config["users"]:
logger.info("No users found in the config file") logger.info("No users found in the config file")
user = authUser() user = authNewUser()
if not user: if not user:
exit() exit()
config["users"].append(user) config["users"].append(user)
@ -69,25 +74,20 @@ def main() -> None:
for plexAlertListener in plexAlertListeners: for plexAlertListener in plexAlertListeners:
plexAlertListener.disconnect() plexAlertListener.disconnect()
def authUser() -> Optional[models.config.User]: def authNewUser() -> Optional[models.config.User]:
response = requests.post("https://plex.tv/api/v2/pins.json?strong=true", headers = { id, code, url = initiateAuth()
"X-Plex-Product": name,
"X-Plex-Client-Identifier": plexClientID,
}).json()
logger.info("Open the below URL in your web browser and sign in:") 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) time.sleep(5)
for i in range(35): for i in range(35):
logger.info(f"Checking whether authentication is successful... ({formatSeconds((i + 1) * 5)})") 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 = { authToken = getAuthToken(id, code)
"X-Plex-Client-Identifier": plexClientID, if authToken:
}).json()
if authCheckResponse["authToken"]:
logger.info("Authentication successful") logger.info("Authentication successful")
serverName = os.environ.get("PLEX_SERVER_NAME") serverName = os.environ.get("PLEX_SERVER_NAME")
if not serverName: if not serverName:
serverName = input("Enter the name of the Plex Media Server you wish to connect to: ") if isInteractive else "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) time.sleep(5)
else: else:
logger.info(f"Authentication timed out ({formatSeconds(180)})") logger.info(f"Authentication timed out ({formatSeconds(180)})")

View file

@ -1,4 +1,4 @@
PlexAPI==4.10.1 PlexAPI==4.10.1
requests==2.26.0 requests==2.31.0
websocket-client==1.3.2 websocket-client==1.3.2
PyYAML==6.0.1 PyYAML==6.0.1

View file

@ -14,8 +14,9 @@ def loadCache() -> None:
with open(cacheFilePath, "r", encoding = "UTF-8") as cacheFile: with open(cacheFilePath, "r", encoding = "UTF-8") as cacheFile:
cache.update(json.load(cacheFile)) cache.update(json.load(cacheFile))
except: except:
os.rename(cacheFilePath, cacheFilePath.replace(".json", f"-{time.time():.0f}.json")) root, ext = os.path.splitext(cacheFilePath)
logger.exception("Failed to parse the application's cache file. A new one will be created.") 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: def getCacheKey(key: str) -> Any:
return cache.get(key) return cache.get(key)
@ -26,4 +27,4 @@ def setCacheKey(key: str, value: Any) -> None:
with open(cacheFilePath, "w", encoding = "UTF-8") as cacheFile: with open(cacheFilePath, "w", encoding = "UTF-8") as cacheFile:
json.dump(cache, cacheFile, separators = (",", ":")) json.dump(cache, cacheFile, separators = (",", ":"))
except: except:
logger.exception("Failed to write to the application's cache file.") logger.exception("Failed to write to the cache file")