'
23 | marker: 'github-sponsors'
24 | active-only: false
25 |
26 | - name: Create pull request
27 | id: cpr
28 | uses: peter-evans/create-pull-request@v6
29 | with:
30 | add-paths: |
31 | README.md
32 | commit-message: 'docs(README): update sponsors'
33 | branch: 'automated/update-sponsors'
34 | delete-branch: true
35 | title: 'docs(README): update sponsors'
36 | body: 'This pull request updates the list of sponsors in the README.'
37 | assignees: 'nwithan8'
38 | labels: |
39 | Automated PR
40 |
41 | - name: Display pull request information
42 | if: ${{ steps.cpr.outputs.pull-request-number }}
43 | run: |
44 | echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
45 | echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"
46 |
47 | - name: Enable Pull Request Automerge
48 | if: steps.cpr.outputs.pull-request-operation == 'created'
49 | uses: peter-evans/enable-pull-request-automerge@v3
50 | with:
51 | token: ${{ secrets.AC_PAT }}
52 | pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
53 | merge-method: squash
54 |
--------------------------------------------------------------------------------
/modules/discord/commands/summary.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Callable
2 |
3 | import discord
4 | from discord import app_commands
5 | from discord.ext import commands
6 |
7 | import modules.logs as logging
8 | from modules.discord import discord_utils
9 | from modules.emojis import EmojiManager
10 | from modules.tautulli.tautulli_connector import (
11 | TautulliConnector,
12 | )
13 |
14 |
15 | class Summary(commands.Cog):
16 | def __init__(self, bot: commands.Bot, tautulli: TautulliConnector, emoji_manager: EmojiManager, admin_check: Callable[[discord.Interaction], bool] = None):
17 | self.bot = bot
18 | self._tautulli = tautulli
19 | self._emoji_manager = emoji_manager
20 | self._admin_check = admin_check
21 | super().__init__() # This is required for the cog to work.
22 | logging.debug("Summary cog loaded.")
23 |
24 | async def check_admin(self, interaction: discord.Interaction) -> bool:
25 | if self._admin_check and not self._admin_check(interaction):
26 | await discord_utils.respond_to_slash_command_with_text(interaction=interaction,
27 | text="You do not have permission to use this command.",
28 | ephemeral=True)
29 | return False
30 |
31 | return True
32 |
33 | @app_commands.command(name="summary", description="Show current activity summary.")
34 | @app_commands.describe(
35 | share="Whether to make the response visible to the channel."
36 | )
37 | async def summary(self, interaction: discord.Interaction, share: Optional[bool] = False) -> None:
38 | if not await self.check_admin(interaction):
39 | return
40 |
41 | # Does NOT include new version reminder or stream termination.
42 | summary = self._tautulli.refresh_data(enable_stream_termination_if_possible=False, emoji_manager=self._emoji_manager)
43 | await summary.reply_to_slash_command(interaction=interaction, ephemeral=not share)
44 |
--------------------------------------------------------------------------------
/modules/statics.py:
--------------------------------------------------------------------------------
1 | # Number 1-9, and A-Z
2 | import subprocess
3 | import sys
4 |
5 | VERSION = "VERSIONADDEDBYGITHUB"
6 | COPYRIGHT = "Copyright © YEARADDEDBYGITHUB Nate Harris. All rights reserved."
7 | UNKNOWN_COMMIT_HASH = "unknown-commit"
8 |
9 | CUSTOM_EMOJIS_FOLDER = "resources/emojis"
10 | CHART_FOLDER = "generated/charts"
11 | CHART_IMAGE_PATH = f"{CHART_FOLDER}/chart.png"
12 |
13 | MONITORED_DISK_SPACE_FOLDER = "/monitor"
14 |
15 | BOT_PREFIX = "tc-"
16 | EMOJI_PREFIX = "tc"
17 |
18 | MAX_EMBED_FIELD_NAME_LENGTH = 200 # 256 - estimated emojis + flairs + safety margin
19 |
20 | KEY_RUN_ARGS_MONITOR_PATH = "run_args_monitor_path"
21 | KEY_RUN_ARGS_CONFIG_PATH = "run_args_config_path"
22 | KEY_RUN_ARGS_LOG_PATH = "run_args_log_path"
23 | KEY_RUN_ARGS_DATABASE_PATH = "run_args_database_path"
24 |
25 | MAX_STREAM_COUNT = 20 # max number of emojis one user can post on a single message
26 |
27 | ASCII_ART = """___________________ ______________________________________________
28 | ___ __/__ |_ / / /__ __/___ _/_ ____/_ __ \__ __ \__ __ \\
29 | __ / __ /| | / / /__ / __ / _ / _ / / /_ /_/ /_ / / /
30 | _ / _ ___ / /_/ / _ / __/ / / /___ / /_/ /_ _, _/_ /_/ /
31 | /_/ /_/ |_\____/ /_/ /___/ \____/ \____/ /_/ |_| /_____/
32 | """
33 |
34 | INFO_SUMMARY = f"""Version: {VERSION}
35 | """
36 |
37 |
38 | def get_sha_hash(sha: str) -> str:
39 | return f"git-{sha[0:7]}"
40 |
41 |
42 | def get_last_commit_hash() -> str:
43 | """
44 | Get the seven character commit hash of the last commit.
45 | """
46 | try:
47 | sha = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip()
48 | except subprocess.SubprocessError:
49 | sha = UNKNOWN_COMMIT_HASH
50 |
51 | return get_sha_hash(sha)
52 |
53 |
54 | def is_git() -> bool:
55 | return "GITHUB" in VERSION
56 |
57 |
58 | def get_version() -> str:
59 | if not is_git():
60 | return VERSION
61 |
62 | return get_last_commit_hash()
63 |
64 |
65 | def splash_logo() -> str:
66 | version = get_version()
67 | return f"""
68 | {ASCII_ART}
69 | Version {version}, Python {sys.version}
70 |
71 | {COPYRIGHT}
72 | """
73 |
--------------------------------------------------------------------------------
/api.py:
--------------------------------------------------------------------------------
1 | import argparse
2 |
3 | from flask import (
4 | Flask,
5 | )
6 |
7 | import modules.logs as logging
8 | from modules.errors import determine_exit_code
9 | from consts import (
10 | DEFAULT_LOG_DIR,
11 | DEFAULT_DATABASE_PATH,
12 | CONSOLE_LOG_LEVEL,
13 | FILE_LOG_LEVEL,
14 | FLASK_ADDRESS,
15 | FLASK_PORT,
16 | FLASK_DATABASE_PATH,
17 | )
18 | from api.routes.index import index
19 | from api.routes.webhooks.tautulli.index import webhooks_tautulli
20 |
21 | APP_NAME = "API"
22 |
23 | # Parse CLI arguments
24 | parser = argparse.ArgumentParser(description="Tauticord API - API for Tauticord")
25 | """
26 | Bot will use config, in order:
27 | 1. Explicit config file path provided as CLI argument, if included, or
28 | 2. Default config file path, if exists, or
29 | 3. Environmental variables
30 | """
31 | parser.add_argument("-l", "--log", help="Log file directory", default=DEFAULT_LOG_DIR)
32 | parser.add_argument("-d", "--database", help="Path to database file", default=DEFAULT_DATABASE_PATH)
33 | args = parser.parse_args()
34 |
35 |
36 | def run_with_potential_exit_on_error(func):
37 | def wrapper(*args, **kwargs):
38 | try:
39 | return func(*args, **kwargs)
40 | except Exception as e:
41 | logging.fatal(f"Fatal error occurred. Shutting down: {e}")
42 | exit_code = determine_exit_code(exception=e)
43 | logging.fatal(f"Exiting with code {exit_code}")
44 | exit(exit_code)
45 |
46 | return wrapper
47 |
48 |
49 | @run_with_potential_exit_on_error
50 | def set_up_logging():
51 | logging.init(app_name=APP_NAME,
52 | console_log_level=CONSOLE_LOG_LEVEL,
53 | log_to_file=True,
54 | log_file_dir=args.log,
55 | file_log_level=FILE_LOG_LEVEL)
56 |
57 |
58 | # Register Flask blueprints
59 | application = Flask(APP_NAME)
60 | application.config[FLASK_DATABASE_PATH] = args.database
61 |
62 | application.register_blueprint(index)
63 | application.register_blueprint(webhooks_tautulli)
64 |
65 | if __name__ == "__main__":
66 | set_up_logging()
67 |
68 | application.run(host=FLASK_ADDRESS, port=FLASK_PORT, debug=False, use_reloader=False)
69 |
--------------------------------------------------------------------------------
/modules/tautulli/models/activity.py:
--------------------------------------------------------------------------------
1 | from typing import Union, List
2 |
3 | from modules import utils
4 | from modules.emojis import EmojiManager
5 | from modules.tautulli.models.session import Session
6 | from modules.time_manager import TimeManager
7 |
8 |
9 | class Activity:
10 | def __init__(self, activity_data, time_manager: TimeManager, emoji_manager: EmojiManager):
11 | self._data = activity_data
12 | self._time_manager = time_manager
13 | self._emoji_manager = emoji_manager
14 |
15 | @property
16 | def stream_count(self) -> int:
17 | value = self._data.get('stream_count', 0)
18 | try:
19 | return int(value)
20 | except:
21 | return 0
22 |
23 | @property
24 | def transcode_count(self) -> int:
25 | # TODO: Tautulli is reporting the wrong data:
26 | # https://github.com/Tautulli/Tautulli/blob/444b138e97a272e110fcb4364e8864348eee71c3/plexpy/webserve.py#L6000
27 | # Judgment there made by transcode_decision
28 | # We want to consider stream_container_decision
29 | return max([0, [s.is_transcoding for s in self.sessions].count(True)])
30 |
31 | @property
32 | def total_bandwidth(self) -> Union[str, None]:
33 | value = self._data.get('total_bandwidth', 0)
34 | try:
35 | return utils.human_bitrate(float(value) * 1024)
36 | except:
37 | return None
38 |
39 | @property
40 | def lan_bandwidth(self) -> Union[str, None]:
41 | value = self._data.get('lan_bandwidth', 0)
42 | try:
43 | return utils.human_bitrate(float(value) * 1024)
44 | except:
45 | return None
46 |
47 | @property
48 | def wan_bandwidth(self) -> Union[str, None]:
49 | total = self._data.get('total_bandwidth', 0)
50 | lan = self._data.get('lan_bandwidth', 0)
51 | value = total - lan
52 | try:
53 | return utils.human_bitrate(float(value) * 1024)
54 | except:
55 | return None
56 |
57 | @property
58 | def sessions(self) -> List[Session]:
59 | return [Session(session_data=session_data, time_manager=self._time_manager) for session_data in
60 | self._data.get('sessions', [])]
61 |
--------------------------------------------------------------------------------
/migrations/base.py:
--------------------------------------------------------------------------------
1 | from abc import abstractmethod
2 | from datetime import datetime
3 |
4 | import modules.logs as logging
5 |
6 |
7 | def _marker(undone: bool = False) -> str:
8 | marker = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
9 |
10 | if undone:
11 | marker += " - UNDONE"
12 |
13 | return marker
14 |
15 |
16 | class BaseMigration:
17 | def __init__(self, number: str, migration_data_directory: str):
18 | self.number = number
19 | self.migration_data_directory = migration_data_directory
20 | self._migration_file = f"{migration_data_directory}/.migration_{self.number}"
21 |
22 | def log(self, message: str):
23 | logging.info(f"Migration {self.number}: {message}")
24 |
25 | def error(self, message: str):
26 | logging.error(f"Migration {self.number}: {message}")
27 |
28 | @abstractmethod
29 | def pre_forward_check(self) -> bool:
30 | """
31 | Check if the forward migration needs to run
32 | """
33 | return True
34 |
35 | @abstractmethod
36 | def forward(self):
37 | """
38 | Run the forward migration
39 | """
40 | pass
41 |
42 | @abstractmethod
43 | def post_forward_check(self) -> bool:
44 | """
45 | Check if the forward migration was successful
46 | """
47 | return True
48 |
49 | @abstractmethod
50 | def pre_backwards_check(self) -> bool:
51 | """
52 | Check if the backwards migration needs to run
53 | """
54 | return True
55 |
56 | @abstractmethod
57 | def backwards(self):
58 | """
59 | Run the backwards migration
60 | """
61 | pass
62 |
63 | @abstractmethod
64 | def post_backwards_check(self) -> bool:
65 | """
66 | Check if the backwards migration was successful
67 | """
68 | return True
69 |
70 | def mark_done(self):
71 | """
72 | Mark the migration as done
73 | """
74 | with open(self._migration_file, 'a') as f:
75 | f.write(f"{_marker()}\n")
76 |
77 | def mark_undone(self):
78 | """
79 | Mark the migration as undone
80 | """
81 | with open(self._migration_file, 'a') as f:
82 | f.write(f"{_marker(undone=True)}\n")
83 |
--------------------------------------------------------------------------------
/modules/discord/commands/recently.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Callable
2 |
3 | import discord
4 | from discord import app_commands
5 | from discord.ext import commands
6 |
7 | import modules.logs as logging
8 | from modules.discord import discord_utils
9 | from modules.discord.models.tautulli_recently_added_summary import TautulliRecentlyAddedSummary
10 | from modules.tautulli.tautulli_connector import (
11 | TautulliConnector,
12 | )
13 |
14 |
15 | class Recently(commands.GroupCog, name="recently"):
16 | def __init__(self, bot: commands.Bot, tautulli: TautulliConnector,
17 | admin_check: Callable[[discord.Interaction], bool] = None):
18 | self.bot = bot
19 | self._tautulli = tautulli
20 | self._admin_check = admin_check
21 | super().__init__() # This is required for the cog to work.
22 | logging.debug("Recently cog loaded.")
23 |
24 | @app_commands.command(name="added", description="Show recently added media.")
25 | @app_commands.describe(
26 | media_type="The type of media to filter by.",
27 | share="Whether to make the response visible to the channel."
28 | )
29 | @app_commands.rename(media_type="media-type")
30 | @app_commands.choices(
31 | media_type=[
32 | discord.app_commands.Choice(name="Movies", value="movie"),
33 | discord.app_commands.Choice(name="Shows", value="show"),
34 | ]
35 | )
36 | async def added(self,
37 | interaction: discord.Interaction,
38 | media_type: Optional[discord.app_commands.Choice[str]] = None,
39 | share: Optional[bool] = False) -> None:
40 | # This command is public, no admin restrictions
41 |
42 | # Defer the response to give more than the default 3 seconds to respond.
43 | await discord_utils.respond_to_slash_command_with_thinking(interaction=interaction, ephemeral=not share)
44 |
45 | limit = 5
46 | if media_type:
47 | media_type = media_type.value
48 |
49 | summary: TautulliRecentlyAddedSummary = self._tautulli.get_recently_added_media_summary(count=limit,
50 | media_type=media_type)
51 | await summary.reply_to_slash_command(interaction=interaction, ephemeral=not share)
52 |
--------------------------------------------------------------------------------
/modules/settings/models/discord.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from modules.settings.models.base import BaseConfig
4 | from modules.utils import mark_exists
5 |
6 |
7 | class StatusMessage(BaseConfig):
8 | enable: bool
9 | activity_name: Optional[str]
10 | custom_message: Optional[str]
11 | show_stream_count: bool
12 |
13 | @property
14 | def should_update_on_startup(self) -> bool:
15 | return self.enable
16 |
17 | @property
18 | def should_update_with_activity(self) -> bool:
19 | return self.enable
20 |
21 | def message(self, stream_count: int, fallback: Optional[str] = None) -> str:
22 | if self.custom_message:
23 | return self.custom_message
24 |
25 | if fallback:
26 | return fallback
27 |
28 | if self.show_stream_count:
29 | return f"Currently {stream_count} stream{'s' if stream_count != 1 else ''} active"
30 | else:
31 | return "https://github.com/nwithan8/tauticord"
32 |
33 | def as_dict(self) -> dict:
34 | return {
35 | "enable": self.enable,
36 | "activity_name": self.activity_name,
37 | "custom_message": self.custom_message,
38 | "show_stream_count": self.show_stream_count
39 | }
40 |
41 |
42 | class Discord(BaseConfig):
43 | admin_ids: list[int]
44 | bot_token: str
45 | announcements_channel_name: Optional[str]
46 | summary_channel_name: str
47 | enable_slash_commands: bool
48 | use_recently_added_carousel_message: bool
49 | use_summary_message: bool
50 | enable_termination: bool
51 | server_id: int
52 | status_message_settings: StatusMessage
53 |
54 | def as_dict(self) -> dict:
55 | return {
56 | "admin_ids": self.admin_ids,
57 | "bot_token": mark_exists(self.bot_token),
58 | "announcements_channel_name": self.announcements_channel_name,
59 | "summary_channel_name": self.summary_channel_name,
60 | "enable_slash_commands": self.enable_slash_commands,
61 | "use_recently_added_carousel_message": self.use_recently_added_carousel_message,
62 | "use_summary_message": self.use_summary_message,
63 | "enable_termination": self.enable_termination,
64 | "server_id": self.server_id,
65 | "status_message_settings": self.status_message_settings.as_dict()
66 | }
67 |
--------------------------------------------------------------------------------
/modules/settings/models/voice_category.py:
--------------------------------------------------------------------------------
1 | from modules.settings.models.base import BaseConfig
2 |
3 | from modules.settings.models.libraries import CombinedLibrary, Library
4 | from modules.settings.models.voice_channel import VoiceChannel
5 |
6 |
7 | class VoiceCategory(BaseConfig):
8 | category_name: str
9 | enable: bool
10 |
11 | def as_dict(self) -> dict:
12 | raise NotImplementedError
13 |
14 | def channel_order(self) -> dict:
15 | raise NotImplementedError
16 |
17 |
18 | class ActivityStats(VoiceCategory):
19 | bandwidth: VoiceChannel
20 | local_bandwidth: VoiceChannel
21 | remote_bandwidth: VoiceChannel
22 | stream_count: VoiceChannel
23 | transcode_count: VoiceChannel
24 | plex_availability: VoiceChannel
25 |
26 | def as_dict(self) -> dict:
27 | return {
28 | "category_name": self.category_name,
29 | "enable": self.enable,
30 | "bandwidth": self.bandwidth.as_dict(),
31 | "local_bandwidth": self.local_bandwidth.as_dict(),
32 | "remote_bandwidth": self.remote_bandwidth.as_dict(),
33 | "stream_count": self.stream_count.as_dict(),
34 | "transcode_count": self.transcode_count.as_dict(),
35 | "plex_availability": self.plex_availability.as_dict()
36 | }
37 |
38 |
39 | class LibraryStats(VoiceCategory):
40 | libraries: list[Library]
41 | combined_libraries: list[CombinedLibrary]
42 | refresh_interval_seconds: int
43 |
44 | def as_dict(self) -> dict:
45 | return {
46 | "category_name": self.category_name,
47 | "enable": self.enable,
48 | "libraries": [library.as_dict() for library in self.libraries],
49 | "combined_libraries": [library.as_dict() for library in self.combined_libraries],
50 | "refresh_interval_seconds": self.refresh_interval_seconds
51 | }
52 |
53 |
54 | class PerformanceStats(VoiceCategory):
55 | cpu: VoiceChannel
56 | memory: VoiceChannel
57 | disk: VoiceChannel
58 | user_count: VoiceChannel
59 |
60 | def as_dict(self) -> dict:
61 | return {
62 | "category_name": self.category_name,
63 | "enable": self.enable,
64 | "cpu": self.cpu.as_dict(),
65 | "memory": self.memory.as_dict(),
66 | "disk": self.disk.as_dict(),
67 | "user_count": self.user_count.as_dict()
68 | }
69 |
--------------------------------------------------------------------------------
/modules/discord/models/tautulli_recently_added_summary.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | import discord
4 |
5 | from modules.discord import discord_utils
6 | from modules.discord.views.recently_added_summary import RecentlyAddedSummaryView
7 | from modules.tautulli.models.recently_added_media_item import RecentlyAddedMediaItem
8 |
9 |
10 | # This data comes from querying the Tautulli API for the X most recently added items
11 | # This is DIFFERENT from the webhooks that help us track HOW MANY items were added in the last X minutes
12 |
13 | class TautulliRecentlyAddedSummary:
14 | def __init__(self,
15 | items: List[RecentlyAddedMediaItem],
16 | footer: str = None):
17 | self.items = items or []
18 | self.footer = footer
19 |
20 | # This is for replying to a slash command with the summary view
21 | async def reply_to_slash_command(self, interaction: discord.Interaction, ephemeral: bool = False) -> None:
22 | if not self.items:
23 | await discord_utils.respond_to_slash_command_with_text(interaction=interaction,
24 | text="No recently added items found.",
25 | ephemeral=True) # Always ephemeral if error
26 | return
27 |
28 | view = RecentlyAddedSummaryView(items=self.items, footer=self.footer)
29 |
30 | # Handles sending the message and updating the embed when interacted with
31 | await view.respond_to_slash_command(interaction=interaction, ephemeral=ephemeral)
32 |
33 | # This is for sending/editing a message in a channel with the summary view
34 | async def send_to_channel(self, message: discord.Message) -> discord.Message:
35 | if not self.items:
36 | return await message.channel.send("No recently added items found.")
37 |
38 | if not self.items:
39 | embed = discord.Embed(title="Recently Added",
40 | description="No recently added items found.")
41 | if self.footer:
42 | embed.set_footer(text=self.footer)
43 | return await discord_utils.send_embed_message(embed=embed, message=message)
44 |
45 | view = RecentlyAddedSummaryView(items=self.items, footer=self.footer)
46 |
47 | # Handles sending the message and updating the embed when interacted with
48 | return await view.send_to_channel(message=message)
49 |
--------------------------------------------------------------------------------
/modules/settings/models/voice_channel.py:
--------------------------------------------------------------------------------
1 | from modules.settings.models.base import BaseConfig
2 | from modules.utils import strip_phantom_space
3 |
4 |
5 | class VoiceChannel(BaseConfig):
6 | name: str
7 | enable: bool
8 | emoji: str
9 | channel_id: int = 0
10 |
11 | @property
12 | def prefix(self) -> str:
13 | emoji = strip_phantom_space(string=self.emoji)
14 | prefix = f"{emoji} {self.name}"
15 | return prefix.strip() # Remove any spaces provided to override default name/emoji
16 |
17 | @property
18 | def channel_id_set(self) -> bool:
19 | return self.channel_id != 0
20 |
21 | def build_channel_name(self, value) -> str:
22 | prefix = self.prefix
23 | if not prefix:
24 | return value
25 | return f"{self.prefix}: {value}"
26 |
27 | def as_dict(self) -> dict:
28 | return {
29 | "name": self.name,
30 | "enable": self.enable,
31 | "emoji": self.emoji,
32 | "channel_id": self.channel_id
33 | }
34 |
35 |
36 | class RecentlyAddedVoiceChannel(VoiceChannel):
37 | hours: int = 24
38 |
39 | def as_dict(self) -> dict:
40 | return {
41 | "name": self.name,
42 | "enable": self.enable,
43 | "emoji": self.emoji,
44 | "channel_id": self.channel_id,
45 | "hours": self.hours
46 | }
47 |
48 |
49 | class LibraryVoiceChannels(BaseConfig):
50 | movie: VoiceChannel
51 | album: VoiceChannel
52 | artist: VoiceChannel
53 | episode: VoiceChannel
54 | series: VoiceChannel
55 | season: VoiceChannel
56 | track: VoiceChannel
57 | recently_added: RecentlyAddedVoiceChannel
58 |
59 | @property
60 | def _channels(self) -> list[VoiceChannel]:
61 | return [self.movie, self.album, self.artist, self.episode, self.series, self.season, self.track, self.recently_added]
62 |
63 | @property
64 | def enabled_channels(self) -> list[VoiceChannel]:
65 | return [channel for channel in self._channels if channel.enable]
66 |
67 | def as_dict(self) -> dict:
68 | return {
69 | "movie": self.movie.as_dict(),
70 | "album": self.album.as_dict(),
71 | "artist": self.artist.as_dict(),
72 | "episode": self.episode.as_dict(),
73 | "series": self.series.as_dict(),
74 | "season": self.season.as_dict(),
75 | "track": self.track.as_dict(),
76 | "recently_added": self.recently_added.as_dict()
77 | }
78 |
--------------------------------------------------------------------------------
/modules/discord/services/library_stats.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import modules.logs as logging
4 | import modules.settings.models as settings_models
5 | import modules.tautulli.tautulli_connector
6 | from modules.analytics import GoogleAnalytics
7 | from modules.discord.services.base_service import BaseService
8 | from modules.emojis import EmojiManager
9 | from modules.tasks.library_stats import LibraryStats
10 |
11 |
12 | class LibraryStatsMonitor(BaseService):
13 | """
14 | A service that monitors library statistics and sends them to Discord. Starts running when the bot is ready.
15 | """
16 |
17 | def __init__(self,
18 | tautulli_connector: modules.tautulli.tautulli_connector.TautulliConnector,
19 | discord_settings: settings_models.Discord,
20 | stats_settings: settings_models.Stats,
21 | emoji_manager: EmojiManager,
22 | analytics: GoogleAnalytics):
23 | super().__init__()
24 | self.tautulli: modules.tautulli.tautulli_connector.TautulliConnector = tautulli_connector
25 | self.guild_id: int = discord_settings.server_id
26 | self.library_refresh_time: int = stats_settings.library.refresh_interval_seconds
27 | self.stats_settings: settings_models.Stats = stats_settings
28 | self.emoji_manager: EmojiManager = emoji_manager
29 | self.analytics: GoogleAnalytics = analytics
30 |
31 | self.library_stats_monitor: LibraryStats = None
32 |
33 | async def enabled(self) -> bool:
34 | return self.stats_settings.library.enable
35 |
36 | async def on_ready(self):
37 | logging.info("Starting Tautulli library stats service...")
38 | voice_category = await self.collect_discord_voice_category(
39 | guild_id=self.guild_id,
40 | category_name=self.stats_settings.library.category_name)
41 | # minimum 5-minute sleep time hard-coded, trust me, don't DDoS your server
42 | refresh_time = max([5 * 60,
43 | self.library_refresh_time])
44 | self.library_stats_monitor = LibraryStats(discord_client=self.bot,
45 | settings=self.stats_settings.library,
46 | tautulli_connector=self.tautulli,
47 | guild_id=self.guild_id,
48 | voice_category=voice_category)
49 | # noinspection PyAsyncCall
50 | asyncio.create_task(self.library_stats_monitor.run_service_override(interval_seconds=refresh_time))
51 |
--------------------------------------------------------------------------------
/modules/tautulli/models/stats.py:
--------------------------------------------------------------------------------
1 | class PlayStatsCategoryData:
2 | def __init__(self, category_name: str, x_axis: list, values: list):
3 | self.category_name: str = category_name
4 | self.x_axis: list = x_axis
5 | self.values: list = values
6 |
7 |
8 | class PlayStats:
9 | def __init__(self, data: dict):
10 | self._data: dict = data
11 | self.categories: list = data.get('categories', [])
12 | self._series: list[dict] = data.get('series', [])
13 |
14 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData:
15 | raise NotImplementedError
16 |
17 | @property
18 | def formatted_data(self) -> dict:
19 | return {
20 | 'tv_shows': self.tv_shows,
21 | 'movies': self.movies,
22 | 'music': self.music
23 | }
24 |
25 | @property
26 | def tv_shows(self) -> PlayStatsCategoryData:
27 | return self._get_category_data(category_name='TV')
28 |
29 | @property
30 | def movies(self) -> PlayStatsCategoryData:
31 | return self._get_category_data(category_name='Movies')
32 |
33 | @property
34 | def music(self) -> PlayStatsCategoryData:
35 | return self._get_category_data(category_name='Music')
36 |
37 |
38 | class PlayCountStats(PlayStats):
39 | def __init__(self, data: dict):
40 | super().__init__(data=data)
41 |
42 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData:
43 | for series in self._series:
44 | if series.get('name', None) == category_name:
45 | return PlayStatsCategoryData(
46 | category_name=category_name,
47 | x_axis=self.categories,
48 | values=series.get('data', [])
49 | )
50 |
51 | return PlayStatsCategoryData(
52 | category_name=category_name,
53 | x_axis=self.categories,
54 | values=[]
55 | )
56 |
57 |
58 | class PlayDurationStats(PlayStats):
59 | def __init__(self, data: dict):
60 | super().__init__(data=data)
61 |
62 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData:
63 | for series in self._series:
64 | if series.get('name', None) == category_name:
65 | return PlayStatsCategoryData(
66 | category_name=category_name,
67 | x_axis=self.categories,
68 | values=series.get('data', [])
69 | )
70 |
71 | return PlayStatsCategoryData(
72 | category_name=category_name,
73 | x_axis=self.categories,
74 | values=[]
75 | )
76 |
--------------------------------------------------------------------------------
/modules/discord/services/performance_stats.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import modules.logs as logging
4 | import modules.settings.models as settings_models
5 | import modules.tautulli.tautulli_connector
6 | from modules.analytics import GoogleAnalytics
7 | from modules.discord.services.base_service import BaseService
8 | from modules.emojis import EmojiManager
9 | from modules.tasks.performance_stats import PerformanceMonitor
10 |
11 |
12 | class PerformanceStatsMonitor(BaseService):
13 | """
14 | A service that monitors performance statistics and sends them to Discord. Starts running when the bot is ready.
15 | """
16 |
17 | def __init__(self,
18 | tautulli_connector: modules.tautulli.tautulli_connector.TautulliConnector,
19 | discord_settings: settings_models.Discord,
20 | stats_settings: settings_models.Stats,
21 | run_args_settings: settings_models.RunArgs,
22 | emoji_manager: EmojiManager,
23 | analytics: GoogleAnalytics):
24 | super().__init__()
25 | self.tautulli: modules.tautulli.tautulli_connector.TautulliConnector = tautulli_connector
26 | self.guild_id: int = discord_settings.server_id
27 | self.run_args_settings: settings_models.RunArgs = run_args_settings
28 | self.stats_settings: settings_models.Stats = stats_settings
29 | self.emoji_manager: EmojiManager = emoji_manager
30 | self.analytics: GoogleAnalytics = analytics
31 |
32 | self.performance_stats_monitor: PerformanceMonitor = None
33 |
34 | async def enabled(self) -> bool:
35 | return self.stats_settings.performance.enable
36 |
37 | async def on_ready(self):
38 | logging.info("Starting performance monitoring service...")
39 | voice_category = await self.collect_discord_voice_category(
40 | guild_id=self.guild_id,
41 | category_name=self.stats_settings.performance.category_name)
42 | # Hard-coded 5-minute refresh time
43 | refresh_time = 5 * 60
44 | self.performance_stats_monitor = PerformanceMonitor(discord_client=self.bot,
45 | settings=self.stats_settings.performance,
46 | tautulli_connector=self.tautulli,
47 | run_args_settings=self.run_args_settings,
48 | guild_id=self.guild_id,
49 | voice_category=voice_category)
50 | # noinspection PyAsyncCall
51 | asyncio.create_task(self.performance_stats_monitor.run_service_override(interval_seconds=refresh_time))
52 |
--------------------------------------------------------------------------------
/modules/versioning.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import Union
3 |
4 | import objectrest
5 |
6 | import modules.logs as logging
7 | from consts import (
8 | GITHUB_REPO,
9 | GITHUB_REPO_MASTER_BRANCH
10 | )
11 | from modules.statics import (
12 | get_version,
13 | is_git,
14 | get_sha_hash,
15 | )
16 |
17 |
18 | def _get_latest_github_release() -> Union[str, None]:
19 | logging.debug('Retrieving latest version information from GitHub')
20 |
21 | url = f'https://api.github.com/repos/{GITHUB_REPO}/releases/latest'
22 | data = objectrest.get_json(url)
23 |
24 | return data.get('tag_name', None) # "tag_name" is the version number (e.g. 2.0.0), not the "name" (e.g. "v2.0.0")
25 |
26 |
27 | def _newer_github_release_available(current_version: str) -> bool:
28 | latest_version = _get_latest_github_release()
29 | if latest_version is None:
30 | return False
31 |
32 | return latest_version != current_version
33 |
34 |
35 | def _get_latest_github_commit() -> Union[str, None]:
36 | logging.debug('Retrieving latest commit information from GitHub')
37 |
38 | url = f'https://api.github.com/repos/{GITHUB_REPO}/commits/{GITHUB_REPO_MASTER_BRANCH}'
39 | data = objectrest.get_json(url)
40 | sha: Union[str, None] = data.get('sha', None)
41 |
42 | if not sha:
43 | return None
44 |
45 | return get_sha_hash(sha=sha)
46 |
47 |
48 | def _newer_github_commit_available(current_commit_hash: str) -> bool:
49 | latest_commit = _get_latest_github_commit()
50 | if latest_commit is None:
51 | return False
52 |
53 | return latest_commit != current_commit_hash
54 |
55 |
56 | def newer_version_available() -> bool:
57 | current_version = get_version()
58 | if is_git():
59 | return _newer_github_commit_available(current_commit_hash=current_version)
60 | else:
61 | return _newer_github_release_available(current_version=current_version)
62 |
63 |
64 | class VersionChecker:
65 | def __init__(self, enable: bool):
66 | self.enable = enable
67 | self._new_version_available = False
68 |
69 | async def monitor_for_new_version(self):
70 | while True:
71 | try:
72 | self._new_version_available = newer_version_available()
73 | if self._new_version_available:
74 | logging.debug(f"New version available")
75 | await asyncio.sleep(60 * 60) # Check for new version every hour
76 | except Exception:
77 | exit(1) # Die on any unhandled exception for this subprocess (i.e. internet connection loss)
78 |
79 | def is_new_version_available(self) -> bool:
80 | if not self.enable:
81 | return False
82 |
83 | return self._new_version_available
84 |
--------------------------------------------------------------------------------
/modules/discord/services/slash_commands.py:
--------------------------------------------------------------------------------
1 | import discord
2 | from discord.ext import commands
3 |
4 | import modules.logs as logging
5 | from modules.discord.commands import (
6 | Most,
7 | Summary,
8 | Recently,
9 | Graphs,
10 | )
11 | from modules.discord.services.base_service import BaseService
12 | from modules.emojis import EmojiManager
13 | from modules.tautulli.tautulli_connector import TautulliConnector
14 |
15 |
16 | class SlashCommandManager(BaseService):
17 | """
18 | A service that manages slash commands for the Discord bot. Starts running when the bot is ready.
19 | """
20 |
21 | def __init__(self, enable_slash_commands: bool, guild_id: int, tautulli: TautulliConnector,
22 | emoji_manager: EmojiManager, admin_ids: list[int] = None):
23 | super().__init__()
24 | self._enable_slash_commands = enable_slash_commands
25 | self._guild_id = guild_id
26 | self._admin_ids = admin_ids or []
27 | self._tautulli = tautulli
28 | self._emoji_manager = emoji_manager
29 | self._synced = False
30 |
31 | async def enabled(self) -> bool:
32 | return True # Always enabled, because need to sync slash commands regardless
33 |
34 | async def on_ready(self):
35 | if self._enable_slash_commands:
36 | for cog in self._cogs_to_add:
37 | await self._add_cog(cog)
38 | logging.info("Slash commands registered.")
39 | else:
40 | logging.info("Slash commands not enabled. Skipping registration...")
41 |
42 | # Need to sync regardless (either adding newly-registered cogs or syncing/removing existing ones)
43 | logging.info("Syncing slash commands...")
44 | if not self._synced:
45 | await self.bot.tree.sync(guild=self._guild)
46 | self._synced = True
47 | logging.info("Slash commands synced.")
48 | else:
49 | logging.info("Slash commands already synced.")
50 |
51 | @property
52 | def _cogs_to_add(self):
53 | return [
54 | Most(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin),
55 | Summary(bot=self.bot, tautulli=self._tautulli, emoji_manager=self._emoji_manager,
56 | admin_check=self.is_admin),
57 | Recently(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin),
58 | Graphs(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin),
59 | ]
60 |
61 | @property
62 | def _active_cogs(self):
63 | return self.bot.cogs
64 |
65 | @property
66 | def _guild(self):
67 | return discord.Object(id=self._guild_id)
68 |
69 | async def _add_cog(self, cog: commands.Cog):
70 | await self.bot.add_cog(cog, guild=self._guild)
71 |
72 | def is_admin(self, interaction: discord.Interaction) -> bool:
73 | return interaction.user.id in self._admin_ids
74 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
134 | # pytype static type analyzer
135 | .pytype/
136 |
137 | # Cython debug symbols
138 | cython_debug/
139 |
140 | # Custom
141 | config.yaml
142 | tauticord.yaml
143 | /docker-compose_test.yml
144 | /scratch.py
145 | /on_host/
146 | 00*.yaml
147 | .migration_*
148 | /reference/
149 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: A request for a new feature in Tauticord.
3 | title: "[FEATURE] -
15 |
16 |
45 |
46 | Administrator (the bot owner) can react to Tauticord's messages to terminate a specific stream (if they have Plex Pass).
47 |
48 | Users can also indicate what libraries they would like monitored. Tauticord will create/update a voice channel for each
49 | library name with item counts every hour.
50 |
51 |
52 |
53 | # Announcements 📢
54 |
55 | See [ANNOUNCEMENTS](documentation/ANNOUNCEMENTS.md).
56 |
57 | # Documentation 📄
58 |
59 | See [DOCUMENTATION](documentation/DOCUMENTATION.md).
60 |
61 | # Development 🛠️
62 |
63 | See [DEVELOPMENT](documentation/DEVELOPMENT.md).
64 |
65 | # Contact 📧
66 |
67 | Please leave a pull request if you would like to contribute.
68 |
69 | Also feel free to check out my other projects here on [GitHub](https://github.com/nwithan8) or join the `#developer`
70 | channel in my Discord server below.
71 |
72 |
77 |
78 | ## Shout-outs 🎉
79 |
80 | ### Sponsors
81 |
82 | [](https://github.com/JetBrainsOfficial)
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | ### Contributors
91 |
92 | |
95 |
96 |
97 | 98 | Nate Harris 99 | 100 | |
101 |
102 |
103 |
104 | 105 | Thomas White 106 | 107 | |
108 |
109 |
110 |
111 | 112 | Tim Wilson 113 | 114 | |
115 |
116 |
117 |
118 | 119 | Ben Waco 120 | 121 | |
122 |
|
125 |
126 |
127 | 128 | Thomas Durieux 129 | 130 | |
131 |
132 |
133 |
134 | 135 | Roy Du 136 | 137 | |
138 |