Skip to content

Commit

Permalink
[fix] Account for parsing different types of Tautulli Discord schemas (
Browse files Browse the repository at this point in the history
  • Loading branch information
nwithan8 authored Nov 29, 2024
1 parent ef990cb commit a773063
Show file tree
Hide file tree
Showing 4 changed files with 249 additions and 27 deletions.
2 changes: 2 additions & 0 deletions tautulli/internal/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,5 @@
session_details_message = """__Quality__: {quality_profile} ({bandwidth}){transcoding}"""
session_progress_message = """__Progress__: {progress} (ETA: {eta})"""

webhook_trigger_pattern = r"action=([a-z_]+)" # Find continuous lowercase letters and underscores

12 changes: 11 additions & 1 deletion tautulli/tools/webhooks/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
from tautulli.tools.webhooks.discord import DiscordWebhookIngestor, DiscordWebhookData, DiscordWebhookAttachment
from tautulli.tools.webhooks.base import (
TautulliWebhookTrigger,
_TautulliWebhook,
)
from tautulli.tools.webhooks.discord import (
DiscordWebhook,
_DiscordWebhookData,
DiscordWebhookAttachment,
PlaybackStateChangeDiscordWebhookData,
RecentlyAddedDiscordWebhookData,
)
66 changes: 66 additions & 0 deletions tautulli/tools/webhooks/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import enum
from abc import abstractmethod, ABC
from typing import Union, Optional

from pydantic import BaseModel


class TautulliWebhookTrigger(enum.Enum):
PLAYBACK_START = "on_play"
PLAYBACK_STOP = "on_stop"
PLAYBACK_PAUSE = "on_pause"
PLAYBACK_RESUME = "on_resume"
PLAYBACK_ERROR = "on_error"
TRANSCODE_DECISION_CHANGE = "on_change"
INTRO_MARKER = "on_intro"
COMMERCIAL_MARKER = "on_commercial"
CREDITS_MARKER = "on_credits"
WATCHED = "on_watched"
BUFFER_WARNING = "on_buffer"
USER_CONCURRENT_STREAMS = "on_concurrent"
USER_NEW_DEVICE = "on_newdevice"
RECENTLY_ADDED = "on_created"
PLEX_SERVER_DOWN = "on_intdown"
PLEX_SERVER_UP = "on_intup"
PLEX_REMOTE_ACCESS_DOWN = "on_extdown"
PLEX_REMOTE_ACCESS_UP = "on_extup"
PLEX_UPDATE_AVAILABLE = "on_pmsupdate"
TAUTULLI_UPDATE_AVAILABLE = "on_plexpyupdate"
TAUTULLI_DATABASE_CORRUPT = "on_plexpydbcorrupt"

@classmethod
def from_string(cls, trigger: str) -> Union["TautulliWebhookTrigger", None]:
"""
Get a Tautulli webhook trigger from a string.
"""
for t in cls:
if t.value == trigger:
return t
return None

def __str__(self):
return self.value


class _TautulliWebhook(BaseModel, ABC):
"""
A webhook from Tautulli
"""
_trigger: Optional[TautulliWebhookTrigger] = None

def __init__(self, /, **data):
super().__init__(**data)

@property
def trigger(self) -> Union[TautulliWebhookTrigger, None]:
if not self._trigger:
self._trigger = self.determine_trigger()

return self._trigger

@abstractmethod
def _determine_trigger(self, **kwargs: dict) -> Union[TautulliWebhookTrigger, None]:
"""
Determine the trigger of a Tautulli webhook.
"""
raise NotImplementedError
196 changes: 170 additions & 26 deletions tautulli/tools/webhooks/discord.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import json
from typing import Optional, List
import re
from typing import Optional, List, Union

from pydantic import BaseModel
from pydantic import BaseModel, Field

from tautulli.internal.static import webhook_trigger_pattern
from tautulli.tools.webhooks.base import _TautulliWebhook, TautulliWebhookTrigger


class DiscordWebhookEmbedImage(BaseModel):
Expand Down Expand Up @@ -33,18 +37,66 @@ class DiscordWebhookEmbed(BaseModel):
fields: Optional[List[DiscordWebhookEmbedField]] = []


class DiscordWebhookData(BaseModel):
class DiscordWebhookAttachment(BaseModel):
"""
An attachment from a Discord-style webhook
"""
filename: str
file_type: str
content: bytes

@classmethod
def from_flask_request_files(cls, files: dict) -> List["DiscordWebhookAttachment"]:
"""
Ingest a list of files from a Discord webhook Flask request
:param files: The files from the Flask request
:return: A list of Discord webhook attachments
"""
# Will probably only have one key, 'files[0]'
# ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1170
attachments = []
for key, value in files.items():
content = value.read()
attachments.append(cls(filename=value.filename, file_type=value.content_type, content=content))
return attachments


class _DiscordWebhookData(BaseModel):
"""
Data from a Discord-style webhook
"""

@classmethod
def from_flask_request_body(cls, body: dict) -> "_DiscordWebhookData":
"""
Ingest a Discord-style webhook from a Flask request body
:param body: The body of the Flask request
:return: Data from the Discord-style webhook
"""
return cls(**body)

@classmethod
def from_fastapi_request_body(cls, body: dict) -> "_DiscordWebhookData":
"""
Ingest a Discord-style webhook from a FastAPI request body
:param body: The body of the FastAPI request
:return: Data from the Discord-style webhook
"""
return cls(**body)


class PlaybackStateChangeDiscordWebhookData(_DiscordWebhookData):
"""
Data from a Discord-style webhook for playback state changes
"""
content: str
username: Optional[str] = None
avatar_url: Optional[str] = None
tts: Optional[bool] = False
embeds: Optional[List[DiscordWebhookEmbed]] = []

@classmethod
def from_flask_request_body(cls, body: dict) -> "DiscordWebhookData":
def from_flask_request_body(cls, body: dict) -> "PlaybackStateChangeDiscordWebhookData":
"""
Ingest a Discord-style webhook from a Flask request body
:param body: The body of the Flask request
Expand All @@ -53,7 +105,7 @@ def from_flask_request_body(cls, body: dict) -> "DiscordWebhookData":
return cls(**body)

@classmethod
def from_fastapi_request_body(cls, body: dict) -> "DiscordWebhookData":
def from_fastapi_request_body(cls, body: dict) -> "PlaybackStateChangeDiscordWebhookData":
"""
Ingest a Discord-style webhook from a FastAPI request body
:param body: The body of the FastAPI request
Expand All @@ -62,38 +114,93 @@ def from_fastapi_request_body(cls, body: dict) -> "DiscordWebhookData":
return cls(**body)


class DiscordWebhookAttachment(BaseModel):
class RecentlyAddedDiscordWebhookData(_DiscordWebhookData):
"""
An attachment from a Discord-style webhook
Data from a Discord-style webhook for recently added media
"""
filename: str
file_type: str
content: bytes
media_type: Optional[str] = None
library_name: Optional[str] = None
title: Optional[str] = None
year_: Optional[str] = Field(None, alias='year')
duration_: Optional[str] = Field(None, alias='duration')
tagline: Optional[str] = None
summary: Optional[str] = None
studio: Optional[str] = None
directors_: Optional[str] = Field(None, alias='directors')
actors_: Optional[str] = Field(None, alias='actors')
genres_: Optional[str] = Field(None, alias='genres')
plex_id: Optional[str] = None
critic_rating_: Optional[str] = Field(None, alias='critic_rating')
audience_rating_: Optional[str] = Field(None, alias='audience_rating')
poster_url: Optional[str] = None

@property
def year(self) -> Union[int, None]:
return int(self.year_) if self.year_ else None

@property
def duration(self) -> Union[int, None]:
"""
Get the duration of the media in minutes
"""
if not self.duration_:
return None

if ':' not in self.duration_:
return int(self.duration_)

hours, minutes = self.duration_.split(':')
return int(hours) * 60 + int(minutes)

@property
def directors(self) -> List[str]:
return self.directors_.split(', ') if self.directors_ else []

@property
def actors(self) -> List[str]:
return self.actors_.split(', ') if self.actors_ else []

@property
def genres(self) -> List[str]:
return self.genres_.split(', ') if self.genres_ else []

@property
def critic_rating(self) -> Union[float, None]:
return float(self.critic_rating_) if self.critic_rating_ else None

@property
def audience_rating(self) -> Union[float, None]:
return float(self.audience_rating_) if self.audience_rating_ else None

@classmethod
def from_flask_request_files(cls, files: dict) -> List["DiscordWebhookAttachment"]:
def from_flask_request_body(cls, body: dict) -> "RecentlyAddedDiscordWebhookData":
"""
Ingest a list of files from a Discord webhook Flask request
:param files: The files from the Flask request
:return: A list of Discord webhook attachments
Ingest a Discord-style webhook from a Flask request body
:param body: The body of the Flask request
:return: Data from the Discord-style webhook
"""
# Will probably only have one key, 'files[0]'
# ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1170
for key, value in files.items():
content = value.read()
yield cls(filename=value.filename, file_type=value.content_type, content=content)
return cls(**body)

@classmethod
def from_fastapi_request_body(cls, body: dict) -> "RecentlyAddedDiscordWebhookData":
"""
Ingest a Discord-style webhook from a FastAPI request body
:param body: The body of the FastAPI request
:return: Data from the Discord-style webhook
"""
return cls(**body)


# ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1148
class DiscordWebhookIngestor(BaseModel):
class DiscordWebhook(_TautulliWebhook):
"""
An ingestor for Discord-style webhooks from Tautulli
A Discord-style webhook from Tautulli
"""
data: DiscordWebhookData
attachments: Optional[List[DiscordWebhookAttachment]]
data: Optional[_DiscordWebhookData] = None
attachments: Optional[List[DiscordWebhookAttachment]] = []

@classmethod
def from_flask_request(cls, request) -> "DiscordWebhookIngestor":
def from_flask_request(cls, request) -> "DiscordWebhook":
"""
Ingest a Discord-style webhook from a Flask request
Expand All @@ -106,9 +213,46 @@ def from_flask_request(cls, request) -> "DiscordWebhookIngestor":
# JSON data is stored in the form field 'payload_json' if files are present
# ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1225
body = json.loads(request.form.get('payload_json', '{}'))

files = request.files
attachments: List[DiscordWebhookAttachment] = DiscordWebhookAttachment.from_flask_request_files(files=files)

data = DiscordWebhookData.from_flask_request_body(body=body)
attachments = list(DiscordWebhookAttachment.from_flask_request_files(files=files))
# Determine how to parse the data based on the webhook trigger
keys = body.keys()
if 'content' in keys:
data = PlaybackStateChangeDiscordWebhookData.from_flask_request_body(body=body)
elif 'plex_id' in keys:
data = RecentlyAddedDiscordWebhookData.from_flask_request_body(body=body)
else:
data = None

return cls(data=data, attachments=attachments)

def _determine_trigger(self, **kwargs) -> Union[TautulliWebhookTrigger, None]:
"""
Determine the trigger of a Discord-style webhook.
:param kwargs: The arguments to determine the trigger
:return: The Discord-style webhook trigger
"""
if isinstance(self.data, RecentlyAddedDiscordWebhookData):
return TautulliWebhookTrigger.RECENTLY_ADDED

if isinstance(self.data, PlaybackStateChangeDiscordWebhookData):
# Couldn't parse webhook data
if not self.data or not self.data.content:
return None

text = self.data.content
if not text:
return None

match = re.search(webhook_trigger_pattern, text)

if not match:
return None

trigger = match.group(1)
return TautulliWebhookTrigger.from_string(trigger=trigger)

return None

0 comments on commit a773063

Please sign in to comment.