diff --git a/README.md b/README.md
index 703d0ea..a50bf98 100755
--- a/README.md
+++ b/README.md
@@ -98,7 +98,14 @@ You will need to set the following environment variables:
| TC_TERMINATE_MESSAGE | No | Message sent to users when a stream is killed | "Your stream has ended." |
| TC_SERVER_NAME | No | Name of the Plex server.
Will use provided; if not provided, will use "Plex"; if provided string is empty, will attempt to extract Plex Media Server name via Tautulli. | "Plex" |
| TC_USE_24_HOUR_TIME | No | Whether to display times in 24-hour time | "False" |
-| TC_ANON_USERS | No | Whether to hide usernames in the streams view | "False" |
+| TC_HIDE_USERNAMES | No | Whether to hide usernames in the streams view | "False" |
+| TC_HIDE_PLATFORMS | No | Whether to hide platforms in the streams view | "False" |
+| TC_HIDE_PLAYER_NAMES | No | Whether to hide player names in the streams view | "False" |
+| TC_HIDE_QUALITY | No | Whether to hide quality profiles in the streams view | "False" |
+| TC_HIDE_BANDWIDTH | No | Whether to hide bandwidth in the streams view | "False" |
+| TC_HIDE_TRANSCODE | No | Whether to hide transcoding statuses in the streams view | "False" |
+| TC_HIDE_PROGRESS | No | Whether to hide stream progress in the streams view | "False" |
+| TC_HIDE_ETA | No | Whether to hide stream ETAs in the streams view | "False" | | | | |
| TC_VC_STATS_CATEGORY_NAME | No | Name of the stats voice channel category | "Tautulli Stats" |
| TC_VC_STREAM_COUNT | No | Whether to display current stream count in a voice channel | "False" |
| TC_VC_TRANSCODE_COUNT | No | Whether to display current transcode count in a voice channel | "False" |
diff --git a/config.yaml.example b/config.yaml.example
index 04d8509..3e7634d 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -36,6 +36,15 @@ Tautulli:
TVEpisodeCount: true
MusicArtistCount: true
MusicTrackCount: true
+ Anonymize:
+ HideUsernames: false
+ HidePlayerNames: false
+ HidePlatforms: false
+ HideQuality: false
+ HideBandwidth: false
+ HideTranscode: false
+ HideProgress: false
+ HideETA: false
Discord:
Connection:
diff --git a/modules/config_parser.py b/modules/config_parser.py
index 850b186..58f1c15 100644
--- a/modules/config_parser.py
+++ b/modules/config_parser.py
@@ -1,12 +1,13 @@
import json
import os
-from typing import List
+from typing import List, Dict, Any
import confuse
import yaml
-from modules import statics
import modules.logs as logging
+from modules import statics
+from modules.text_manager import TextManager
def _extract_bool(value):
@@ -90,7 +91,7 @@ def refresh_interval(self) -> int:
def server_name(self) -> str:
return self._customization._get_value(key='ServerName', default="Plex",
env_name_override="TC_SERVER_NAME")
-
+
@property
def anonymous_users(self) -> bool:
value = self._customization._get_value(key='AnonymousUsers', default=False,
@@ -213,7 +214,7 @@ def show_music_track_count(self) -> bool:
return _extract_bool(value)
@property
- def voice_channel_settings(self):
+ def voice_channel_settings(self) -> Dict[str, Any]:
return {
statics.KEY_STATS_CATEGORY_NAME: self.stats_voice_channel_category_name,
statics.KEY_COUNT: self.display_stream_count,
@@ -235,7 +236,7 @@ def voice_channel_settings(self):
@property
def any_live_stats_channels_enabled(self) -> bool:
keys = [statics.KEY_COUNT, statics.KEY_TRANSCODE_COUNT, statics.KEY_BANDWIDTH,
- statics.KEY_LAN_BANDWIDTH, statics.KEY_REMOTE_BANDWIDTH, statics.KEY_PLEX_STATUS]
+ statics.KEY_LAN_BANDWIDTH, statics.KEY_REMOTE_BANDWIDTH, statics.KEY_PLEX_STATUS]
return any([self.voice_channel_settings.get(key, False) for key in keys])
@property
@@ -243,6 +244,71 @@ def any_library_stats_channels_enabled(self) -> bool:
keys = [statics.KEY_STATS]
return any([self.voice_channel_settings.get(key, False) for key in keys])
+ @property
+ def _anonymize_rules(self) -> ConfigSection:
+ return self._customization._get_subsection(key="Anonymize")
+
+ @property
+ def _anonymize_hide_usernames(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideUsernames", default=False,
+ env_name_override="TC_HIDE_USERNAMES")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_platforms(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HidePlatforms", default=False,
+ env_name_override="TC_HIDE_PLATFORMS")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_player_names(self) -> str:
+ return self._anonymize_rules._get_value(key="HidePlayerNames", default=False,
+ env_name_override="TC_HIDE_PLAYER_NAMES")
+
+ @property
+ def _anonymize_hide_quality(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideQuality", default=False,
+ env_name_override="TC_HIDE_QUALITY")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_bandwidth(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideBandwidth", default=False,
+ env_name_override="TC_HIDE_BANDWIDTH")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_transcode_decision(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideTranscode", default=False,
+ env_name_override="TC_HIDE_TRANSCODE")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_progress(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideProgress", default=False,
+ env_name_override="TC_HIDE_PROGRESS")
+ return _extract_bool(value)
+
+ @property
+ def _anonymize_hide_eta(self) -> bool:
+ value = self._anonymize_rules._get_value(key="HideETA", default=False,
+ env_name_override="TC_HIDE_ETA")
+ return _extract_bool(value)
+
+ @property
+ def text_manager(self) -> TextManager:
+ anonymous_rules = {
+ statics.KEY_HIDE_USERNAMES: self._anonymize_hide_usernames,
+ statics.KEY_HIDE_PLAYER_NAMES: self._anonymize_hide_player_names,
+ statics.KEY_HIDE_PLATFORMS: self._anonymize_hide_platforms,
+ statics.KEY_HIDE_QUALITY: self._anonymize_hide_quality,
+ statics.KEY_HIDE_BANDWIDTH: self._anonymize_hide_bandwidth,
+ statics.KEY_HIDE_TRANSCODING: self._anonymize_hide_transcode_decision,
+ statics.KEY_HIDE_PROGRESS: self._anonymize_hide_progress,
+ statics.KEY_HIDE_ETA: self._anonymize_hide_eta,
+ }
+ return TextManager(anon_rules=anonymous_rules)
+
class DiscordConfig(ConfigSection):
def __init__(self, data, pull_from_env: bool = True):
@@ -270,7 +336,8 @@ def admin_ids(self) -> List[str]:
@property
def channel_name(self) -> str:
- return self._connection._get_value(key="ChannelName", default="tauticord", env_name_override="TC_DISCORD_CHANNEL_NAME")
+ return self._connection._get_value(key="ChannelName", default="tauticord",
+ env_name_override="TC_DISCORD_CHANNEL_NAME")
@property
def _customization(self) -> ConfigSection:
diff --git a/modules/statics.py b/modules/statics.py
index 281b550..4ab680d 100644
--- a/modules/statics.py
+++ b/modules/statics.py
@@ -4,17 +4,6 @@
STANDARD_EMOJIS_FOLDER = "resources/emojis/standard"
NITRO_EMOJIS_FOLDER = "resources/emojis/nitro"
-sessions_message = """{stream_count} {word}"""
-transcodes_message = """{transcode_count} {word}"""
-bandwidth_message = """{emoji} {bandwidth}"""
-lan_bandwidth_message = """({emoji} {bandwidth})"""
-
-session_title_message = """{emoji} | {icon} {media_type_icon} *{title}*"""
-session_user_message = """{emoji} **{username}**"""
-session_player_message = """{emoji} **{product}** ({player})"""
-session_details_message = """{emoji} **{quality_profile}** ({bandwidth}){transcoding}"""
-session_progress_message = """{emoji} **{progress}** (ETA: {eta})"""
-
voice_channel_order = {
'count': 1,
'transcodes': 2,
@@ -40,4 +29,16 @@
KEY_SHOW_MUSIC_ARTISTS = "show_music_artists"
KEY_SHOW_MUSIC_TRACKS = "show_music_tracks"
+KEY_ANONYMOUS_SETTINGS = "anonymous_settings"
+KEY_HIDE_USERNAMES = "hide_usernames"
+KEY_HIDE_PLATFORMS = "hide_platforms"
+KEY_HIDE_PLAYER_NAMES = "anonymize_players"
+KEY_HIDE_QUALITY = "hide_quality"
+KEY_HIDE_BANDWIDTH = "hide_bandwidth"
+KEY_HIDE_TRANSCODING = "hide_transcoding"
+KEY_HIDE_PROGRESS = "hide_progress"
+KEY_HIDE_ETA = "hide_eta"
+KEY_DISABLE_TERMINATION = "hide_termination"
+
+
MAX_STREAM_COUNT = 36
diff --git a/modules/tautulli_connector.py b/modules/tautulli_connector.py
index 8334f56..8818df6 100644
--- a/modules/tautulli_connector.py
+++ b/modules/tautulli_connector.py
@@ -4,10 +4,10 @@
import tautulli
import modules.logs as logging
-import modules.statics as statics
from modules import utils
from modules.emojis import EmojiManager
from modules.settings_transports import LibraryVoiceChannelsVisibilities
+from modules.text_manager import TextManager
session_ids = {}
@@ -126,32 +126,6 @@ def transcoding_stub(self) -> str:
def stream_container_decision(self) -> str:
return self._data['stream_container_decision']
- def get_session_title(self, session_number: int, emoji_manager: EmojiManager) -> str:
- emoji = emoji_manager.emoji_from_stream_number(number=session_number)
- return statics.session_title_message.format(emoji=emoji,
- icon=self.get_status_icon(emoji_manager=emoji_manager),
- media_type_icon=self.get_type_icon(emoji_manager=emoji_manager),
- title=self.title)
-
- def get_session_user(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
- emoji = emoji_manager.get_emoji(key="person")
- return statics.session_user_message.format(emoji=emoji, username="Anonymous" if anon_users else self.username)
-
- def get_session_player(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
- emoji = emoji_manager.get_emoji(key="device")
- return statics.session_player_message.format(emoji=emoji, product=self.product, player="Anonymous" if anon_users else self.player)
-
- def get_session_details(self, emoji_manager: EmojiManager) -> str:
- emoji = emoji_manager.get_emoji(key="resolution")
- return statics.session_details_message.format(emoji=emoji, quality_profile=self.quality_profile,
- bandwidth=self.bandwidth,
- transcoding=self.transcoding_stub)
-
- def get_session_progress(self, emoji_manager: EmojiManager) -> str:
- emoji = emoji_manager.get_emoji(key="progress")
- return statics.session_progress_message.format(emoji=emoji, progress=self.progress_marker, eta=self.eta)
-
-
class Activity:
def __init__(self, activity_data, time_settings: dict, emoji_manager: EmojiManager):
self._data = activity_data
@@ -200,24 +174,6 @@ def wan_bandwidth(self) -> Union[str, None]:
except:
return None
- def get_message(self) -> str:
- overview_message = ""
- if self.stream_count > 0:
- overview_message += statics.sessions_message.format(stream_count=self.stream_count,
- word=utils.make_plural(word='stream',
- count=self.stream_count))
- if self.transcode_count > 0:
- overview_message += f" ({statics.transcodes_message.format(transcode_count=self.transcode_count, word=utils.make_plural(word='transcode', count=self.transcode_count))})"
-
- if self.total_bandwidth:
- bandwidth_emoji = self._emoji_manager.get_emoji(key='bandwidth')
- overview_message += f" @ {statics.bandwidth_message.format(emoji=bandwidth_emoji, bandwidth=self.total_bandwidth)}"
- if self.lan_bandwidth:
- lan_bandwidth_emoji = self._emoji_manager.get_emoji(key='home')
- overview_message += f" {statics.lan_bandwidth_message.format(emoji=lan_bandwidth_emoji, bandwidth=self.lan_bandwidth)}"
-
- return overview_message
-
@property
def sessions(self) -> List[Session]:
return [Session(session_data=session_data, time_settings=self._time_settings) for session_data in
@@ -229,27 +185,15 @@ def __init__(self, session: Session, session_number: int):
self._session = session
self._session_number = session_number
- def get_title(self, emoji_manager: EmojiManager) -> str:
+ def get_title(self, emoji_manager: EmojiManager, text_manager: TextManager) -> str:
try:
- return self._session.get_session_title(session_number=self._session_number, emoji_manager=emoji_manager)
+ return text_manager.session_title(session=self._session, session_number=self._session_number, emoji_manager=emoji_manager)
except Exception as title_exception:
return "Unknown"
- def get_player(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
- return self._session.get_session_player(emoji_manager=emoji_manager, anon_users=anon_users)
-
- def get_user(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
- return self._session.get_session_user(emoji_manager=emoji_manager, anon_users=anon_users)
-
- def get_details(self, emoji_manager: EmojiManager) -> str:
- return self._session.get_session_details(emoji_manager=emoji_manager)
-
- def get_progress(self, emoji_manager: EmojiManager) -> str:
- return self._session.get_session_progress(emoji_manager=emoji_manager)
-
- def get_body(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
+ def get_body(self, emoji_manager: EmojiManager, text_manager: TextManager) -> str:
try:
- return f"{self.get_user(emoji_manager=emoji_manager, anon_users=anon_users)}\n{self.get_player(emoji_manager=emoji_manager, anon_users=anon_users)}\n{self.get_details(emoji_manager=emoji_manager)}\n{self.get_progress(emoji_manager=emoji_manager)}"
+ return text_manager.session_body(session=self._session, emoji_manager=emoji_manager)
except Exception as body_exception:
logging.error(str(body_exception))
return f"Could not display data for session {self._session_number}"
@@ -257,20 +201,20 @@ def get_body(self, emoji_manager: EmojiManager, anon_users: bool) -> str:
class TautulliDataResponse:
def __init__(self,
- overview_message: str,
+ activity: Union[Activity, None],
server_name: str,
emoji_manager: EmojiManager,
+ text_manager: TextManager,
streams_info: List[TautulliStreamInfo] = None,
plex_pass: bool = False,
- error_occurred: bool = False,
- anon_users: bool = False):
- self._overview_message = overview_message
+ error_occurred: bool = False):
+ self._activity = activity
self._streams = streams_info or []
self.plex_pass = plex_pass
self.error = error_occurred
self._emoji_manager = emoji_manager
self._server_name = server_name
- self.anon_users = anon_users
+ self._text_manager = text_manager
@property
def embed(self) -> discord.Embed:
@@ -278,11 +222,13 @@ def embed(self) -> discord.Embed:
return discord.Embed(title="No current activity")
embed = discord.Embed(title=f"Current activity on {self._server_name}")
for stream in self._streams:
- embed.add_field(name=stream.get_title(emoji_manager=self._emoji_manager),
- value=stream.get_body(emoji_manager=self._emoji_manager, anon_users=self.anon_users), inline=False)
- footer_text = self._overview_message
- if self.plex_pass:
- footer_text += f"\n\nTo terminate a stream, react with the stream number."
+ embed.add_field(name=stream.get_title(emoji_manager=self._emoji_manager, text_manager=self._text_manager),
+ value=stream.get_body(emoji_manager=self._emoji_manager, text_manager=self._text_manager),
+ inline=False)
+ footer_text = self._text_manager.overview_footer(no_connection=self.error,
+ activity=self._activity,
+ emoji_manager=self._emoji_manager,
+ add_termination_tip=self.plex_pass)
embed.set_footer(text=footer_text)
return embed
@@ -295,11 +241,11 @@ def __init__(self,
analytics,
plex_pass: bool,
time_settings: dict,
+ text_manager: TextManager,
server_name: str = None,
- anon_users: bool = False,
):
self.base_url = base_url
- self.anon_users = anon_users
+ self.text_manager = text_manager
self.api_key = api_key
try:
self.api = tautulli.RawAPI(base_url=base_url, api_key=api_key)
@@ -343,15 +289,17 @@ def refresh_data(self, emoji_manager: EmojiManager) -> Tuple[
self._error_and_analytics(error_message=err, function_name='refresh_data (ValueError)')
pass
logging.debug(f"Count: {count}")
- return TautulliDataResponse(overview_message=activity.get_message(),
+ return TautulliDataResponse(activity=activity,
emoji_manager=emoji_manager,
+ text_manager=self.text_manager,
streams_info=session_details,
plex_pass=self.plex_pass,
- server_name=self.server_name,
- anon_users=self.anon_users), count, activity, self.is_plex_server_online()
+ server_name=self.server_name), count, activity, self.is_plex_server_online()
except KeyError as e:
self._error_and_analytics(error_message=e, function_name='refresh_data (KeyError)')
- return TautulliDataResponse(overview_message="**Connection lost.**", emoji_manager=emoji_manager,
+ return TautulliDataResponse(activity=None,
+ emoji_manager=emoji_manager,
+ text_manager=self.text_manager,
error_occurred=True,
server_name=self.server_name), 0, None, False # If Tautulli is offline, assume Plex is offline
diff --git a/modules/text_manager.py b/modules/text_manager.py
new file mode 100644
index 0000000..1b1bf17
--- /dev/null
+++ b/modules/text_manager.py
@@ -0,0 +1,127 @@
+from typing import Dict, Any, Union
+
+from modules import statics, utils
+from modules.emojis import EmojiManager
+
+
+class TextManager:
+ """
+ Manages text formatting and anonymization.
+ """
+ def __init__(self, anon_rules: Dict[str, Any]) -> None:
+ self._anon_rules: dict = anon_rules
+ self._anon_hide_usernames: bool = anon_rules.get(statics.KEY_HIDE_USERNAMES, False)
+ self._anon_hide_player_names: bool = anon_rules.get(statics.KEY_HIDE_PLAYER_NAMES, False)
+ self._anon_hide_platforms: bool = anon_rules.get(statics.KEY_HIDE_PLATFORMS, False)
+ self._anon_hide_quality: bool = anon_rules.get(statics.KEY_HIDE_QUALITY, False)
+ self._anon_hide_bandwidth: bool = anon_rules.get(statics.KEY_HIDE_BANDWIDTH, False)
+ self._anon_hide_transcoding: bool = anon_rules.get(statics.KEY_HIDE_TRANSCODING, False)
+ self._anon_hide_progress: bool = anon_rules.get(statics.KEY_HIDE_PROGRESS, False)
+ self._anon_hide_eta: bool = anon_rules.get(statics.KEY_HIDE_ETA, False)
+
+ def _session_user_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]:
+ if self._anon_hide_usernames:
+ return None
+
+ emoji = emoji_manager.get_emoji(key="person")
+ username = session.username
+ stub = f"""{emoji} **{username}**"""
+
+ return stub
+
+ def _session_player_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]:
+ if self._anon_hide_platforms and self._anon_hide_player_names:
+ return None
+
+ emoji = emoji_manager.get_emoji(key="device")
+ player = None if self._anon_hide_player_names else session.player
+ product = None if self._anon_hide_platforms else session.product
+
+ stub = f"""{emoji}"""
+ if player is not None:
+ stub += f""" **{player}**"""
+ # Only optionally show product if player is shown.
+ if product is not None:
+ stub += f""" ({product})"""
+
+ return stub
+
+ def _session_details_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]:
+ if self._anon_hide_quality and self._anon_hide_bandwidth and self._anon_hide_transcoding:
+ return None
+
+ quality_profile = None if self._anon_hide_quality else session.quality_profile
+ bandwidth = None if self._anon_hide_bandwidth else session.bandwidth
+ transcoding = None if self._anon_hide_transcoding else session.transcoding_stub
+
+ emoji = emoji_manager.get_emoji(key="resolution")
+ stub = f"""{emoji}"""
+ if quality_profile is not None:
+ stub += f""" **{quality_profile}**"""
+ # Only optionally show bandwidth if quality profile is shown.
+ if bandwidth is not None:
+ stub += f""" ({bandwidth})"""
+ if transcoding is not None:
+ stub += f"""{transcoding}"""
+
+ return stub
+
+ def _session_progress_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]:
+ if self._anon_hide_progress:
+ return None
+
+ emoji = emoji_manager.get_emoji(key="progress")
+ progress = session.progress_marker
+ stub = f"""{emoji} **{progress}**"""
+ if not self._anon_hide_eta:
+ eta = session.eta
+ stub += f""" (ETA: {eta})"""
+
+ return stub
+
+ def session_title(self, session, session_number: int, emoji_manager: EmojiManager) -> str:
+ emoji = emoji_manager.emoji_from_stream_number(number=session_number)
+ icon = session.get_status_icon(emoji_manager=emoji_manager)
+ media_type_icon = session.get_type_icon(emoji_manager=emoji_manager)
+ title = session.title
+ return f"""{emoji} | {icon} {media_type_icon} *{title}*"""
+
+ def session_body(self, session, emoji_manager: EmojiManager) -> str:
+ user_message = self._session_user_message(session=session, emoji_manager=emoji_manager)
+ player_message = self._session_player_message(session=session, emoji_manager=emoji_manager)
+ details_message = self._session_details_message(session=session, emoji_manager=emoji_manager)
+ progress_message = self._session_progress_message(session=session, emoji_manager=emoji_manager)
+
+ stubs = [user_message, player_message, details_message, progress_message]
+ stubs = [stub for stub in stubs if stub is not None]
+ return "\n".join(stubs)
+
+ def overview_footer(self, no_connection: bool, activity, emoji_manager: EmojiManager, add_termination_tip: bool) -> str:
+ if no_connection or activity is None:
+ return "**Connection lost.**"
+
+ if activity.stream_count == 0:
+ return "**No active sessions.**"
+
+ stream_count = activity.stream_count
+ stream_count_word = utils.make_plural(word='stream', count=stream_count)
+ overview_message = f"""{stream_count} {stream_count_word}"""
+
+ if activity.transcode_count > 0 and not self._anon_hide_transcoding:
+ transcode_count = activity.transcode_count
+ transcode_count_word = utils.make_plural(word='transcode', count=transcode_count)
+ overview_message += f""" ({transcode_count} {transcode_count_word})"""
+
+ if activity.total_bandwidth and not self._anon_hide_bandwidth:
+ bandwidth_emoji = emoji_manager.get_emoji(key='bandwidth')
+ bandwidth = activity.total_bandwidth
+ overview_message += f""" @ {bandwidth_emoji} {bandwidth}"""
+ if activity.lan_bandwidth:
+ lan_bandwidth_emoji = emoji_manager.get_emoji(key='home')
+ lan_bandwidth = activity.lan_bandwidth
+ overview_message += f""" {lan_bandwidth_emoji} {lan_bandwidth}"""
+
+ if add_termination_tip:
+ overview_message += f"\n\nTo terminate a stream, react with the stream number."
+
+ return overview_message
diff --git a/run.py b/run.py
index 7996e79..e7cd149 100755
--- a/run.py
+++ b/run.py
@@ -59,7 +59,7 @@
plex_pass=config.tautulli.has_plex_pass,
time_settings=config.tautulli.time_settings,
server_name=config.tautulli.server_name,
- anon_users=config.tautulli.anonymous_users,
+ text_manager=config.tautulli.text_manager,
)
discord_connector = discord.DiscordConnector(
diff --git a/templates/tauticord.xml b/templates/tauticord.xml
index 480fd6f..a60c6ba 100644
--- a/templates/tauticord.xml
+++ b/templates/tauticord.xml
@@ -29,7 +29,14 @@
Plex
Your stream has ended.
False
- False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
Tautulli Stats
False
False