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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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