diff --git a/.gitignore b/.gitignore index 1ad0889..0406189 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ __pycache__ -config.ini +config.yaml cordarr.db .DS_Store \ No newline at end of file diff --git a/README.md b/README.md index b90d92c..4f9d21e 100644 --- a/README.md +++ b/README.md @@ -25,37 +25,30 @@ CordArr is a self-hosted Discord bot that allows you to add new movies or shows to your Radarr/Sonarr libraries, and allow users to create temporary Jellyfin accounts on your server. -*NOTE: Sonarr support is currently in the works* - # Instructions CordArr is built on Python and requires you to install all of the dependencies in the `requirements.txt` file. To do this, you can run the pip install command like `pip install -r requirements.txt` -On first run you will likely get a critical warning in your console, don't worry, this is expected. It will automatically create a `config.ini` file for you in the root of the directory with all of the necessary configuration options. - -Fill out the configuration options, then re-run the bot, and everything *should* just work. For information on each configuration option, look below. +You must then fill out the `config.yaml` file with the necessary information. If the file does not exist already, run the bot once to generate the file. +# Configuration +## BOT_INFO Field | Description --- | --- BOT_TOKEN | The token for your bot. Create a bot at [discord.com/developers](https://discord.com/developers) -RADARR_HOST_URL | URL for your Radarr instance (e.g. http://localhost:7878) -RADARR_API_KEY | API key for Radarr, found in `Settings > General > API Key` -ROOT_FOLDER_PATH | Path for media root folder, found at the bottom of the page in `Settings > Media Management` -QUALITY_PROFILE_ID | ID for the quality profile on Radarr (in order to get a list of your quality profiles and their IDs, set the other fields first, then re-run CordArr, the config.ini file will update with this information) -ENABLE_JELLYFIN_TEMP_ACCOUNT | `true/false` : Whether or not to enable the `/newaccount` command allowing users to create temporary Jellyfin accounts - -
- -If you choose to enable the Jellyfin temp accounts features, these fields will also be required +## RADARR / SONARR | OPTIONAL Field | Description --- | --- -JELLYFIN_URL | URL for your Jellyfin server (e.g. http://localhost:8096) -JELLYFIN_API_KEY | API key for Jellyfin - can be created in `Dashboard > API Keys` -ACCOUNT_TIME | Amount of time, in hours, that temporary Jellyfin accounts should exist before being deleted -SIMPLE_ACCOUNTS | `true/false` : Whether or not to have simple dictionary word passwords for temporary accounts +HOST_URL | URL for your Radarr/Sonarr instance (e.g. http://localhost:7878) +API_KEY | API key for Radarr/Sonarr, found in `Settings > General > API Key` +ROOT_FOLDER_PATH | Folder path found at the bottom of the page in `Settings > Media Management` +QUALITY_PROFILE_ID | ID for the quality profile to download content in. Run the bot once to get a list of profiles and their IDs -
-
- -If you have any questions, feel free to email at [contact@pkrm.dev](mailto:contact@pkrm.dev). Thank you for checking out CordArr, and happy coding. +## JELLYFIN | OPTIONAL +Field | Description +--- | --- +URL | URL for your Jellyfin server (e.g. http://localhost:8096) +API_KEY | API key for Jellyfin - can be created in `Dashboard > API Keys` +ACCOUNT_TIME | Amount of time, in hours, accounts should exist before being deleted +SIMPLE_PASSWORDS | `true/false` : Whether or not to have simple dictionary word passwords for temporary accounts \ No newline at end of file diff --git a/code/bot.py b/code/bot.py index e8dc418..b7654c6 100644 --- a/code/bot.py +++ b/code/bot.py @@ -1,13 +1,10 @@ import discord from discord.ext import commands from discord.ext import tasks -import datetime -import sqlite3 import os -from validate_config import create_config -from func.jellyfin import delete_jellyfin_account -from global_variables import LOG, BOT_TOKEN +import utils.config as config +from utils.jellyfin_delete import delete_accounts class MyBot(commands.Bot): @@ -18,7 +15,6 @@ class MyBot(commands.Bot): ) async def setup_hook(self): - create_config() delete_old_temp_accounts.start() for ext in os.listdir("./code/cogs"): if ext.endswith(".py"): @@ -31,21 +27,14 @@ bot.remove_command("help") @bot.event async def on_ready(): - LOG.info(f"{bot.user} has connected to Discord.") + config.LOG.info(f"{bot.user} has connected to Discord.") @tasks.loop(seconds=60) async def delete_old_temp_accounts(): - # Get all jellyfin user IDs that have passed their deletion time - db = sqlite3.connect("cordarr.db") - cursor = db.cursor() - cursor.execute("SELECT jellyfin_user_id FROM jellyfin_accounts WHERE deletion_time < ?", (datetime.datetime.now(),)) - jellyfin_user_ids = cursor.fetchall() - - # Delete the Jellyfin accounts - for jellyfin_user_id in jellyfin_user_ids: - delete_jellyfin_account(jellyfin_user_id[0]) + delete_accounts() if __name__ == "__main__": - bot.run(BOT_TOKEN) + config.load_config() + bot.run(config.BOT_TOKEN) diff --git a/code/cogs/error.py b/code/cogs/error.py index 2752400..6b6e557 100644 --- a/code/cogs/error.py +++ b/code/cogs/error.py @@ -15,10 +15,16 @@ class slash_handlers(commands.Cog): ): embed = discord.Embed( title="Jellyfin Account Creation Disabled", - description=f"The owner of {self.bot.user.mention} has disabled the ability to create temporary Jellyfin accounts. Contact an administrator for more information.", - color=0xD01B86 + description=( + f"The owner of {self.bot.user.mention} has disabled the" + " ability to create temporary Jellyfin accounts. Contact" + " an administrator for more information." + ), + color=0xD01B86, + ) + await interaction.response.send_message( + embed=embed, ephemeral=True ) - await interaction.response.send_message(embed=embed, ephemeral=True) else: raise error diff --git a/code/cogs/newaccount.py b/code/cogs/newaccount.py index db5abd0..b341147 100644 --- a/code/cogs/newaccount.py +++ b/code/cogs/newaccount.py @@ -3,8 +3,12 @@ from discord import app_commands from discord.ext import commands import sqlite3 -from func.jellyfin import create_jellyfin_account -from global_variables import JELLYFIN_URL, ENABLE_JELLYFIN_TEMP_ACCOUNTS, ACCOUNT_TIME +from utils.jellyfin_create import create_jellyfin_account +from utils.config import ( + JELLYFIN_URL, + JELLYFIN_ENABLED, + ACCOUNT_TIME, +) class NewAccount(commands.Cog): @@ -12,47 +16,77 @@ class NewAccount(commands.Cog): self.bot = bot @app_commands.command() - @app_commands.check(lambda inter: ENABLE_JELLYFIN_TEMP_ACCOUNTS) - async def newaccount(self, interaction: discord.Interaction): - "Create a new temporary Jellyfin account" + @app_commands.check(lambda inter: JELLYFIN_ENABLED) + async def newaccount(self, interaction: discord.Interaction) -> None: + """Create a new temporary Jellyfin account""" # Make sure the user doesn't already have an account db = sqlite3.connect("cordarr.db") cursor = db.cursor() cursor.execute( - "SELECT * FROM jellyfin_accounts WHERE user_id = ?", (interaction.user.id,) + "SELECT * FROM jellyfin_accounts WHERE user_id = ?", + (interaction.user.id,), ) - if cursor.fetchone(): + account = cursor.fetchone() + db.close() + # Account already allocated + if account: embed = discord.Embed( title="Account Already Exists", - description="Look at your previous DMs with me to find your account information. You will be permitted to create a new account after your current one expires.", - color=0xD01B86 + description=( + "Look at your previous DMs with me to find your account" + " information. You will be permitted to create a new" + " account after your current one expires." + ), + color=0xD01B86, + ) + return await interaction.response.send_message( + embed=embed, ephemeral=True ) - return await interaction.response.send_message(embed=embed, ephemeral=True) # Create a new Jellyfin account for the user response = create_jellyfin_account(interaction.user.id) if response: embed = discord.Embed( title="Account Created", - description="Your account has been successfully created. Check your DMs for your account information.", - color=0xD01B86 + description=( + "Your account has been successfully created. Check your" + " DMs for your account information." + ), + color=0xD01B86, + ) + await interaction.response.send_message( + embed=embed, ephemeral=True ) - await interaction.response.send_message(embed=embed, ephemeral=True) # Send the user their account information embed = discord.Embed( title="Jellyfin Account Information", - description=f"Here is your temporary account information. You will need this to access the Jellyfin server.\n\n**Server URL:** `{JELLYFIN_URL}`\n**Username:** `{response[0]}`\n**Password:** `{response[1]}`\n\nYour account will be automatically deleted in {ACCOUNT_TIME} hours.", - color=0xD01B86 + description=( + # fmt: off + "Here is your temporary account information.\n\n" + f"**Server URL:** `{JELLYFIN_URL}`\n" + f"**Username:** `{response[0]}`\n" + f"**Password:** `{response[1]}`\n\n" + "Your account will be automatically deleted in" + f" {ACCOUNT_TIME} hours." + # fmt: on + ), + color=0xD01B86, ) await interaction.user.send(embed=embed) + # If account not created for some reason else: embed = discord.Embed( title="Unknown Error Occured", - description="Error creating Jellyfin account. Please try again. If the error persists, contact an administrator.", - color=0xD01B86 + description=( + "Error creating Jellyfin account. Please try again. If the" + " error persists, contact an administrator." + ), + color=0xD01B86, + ) + return await interaction.response.send_message( + embed=embed, ephemeral=True ) - return await interaction.response.send_message(embed=embed, ephemeral=True) async def setup(bot): diff --git a/code/cogs/request.py b/code/cogs/request.py index fb1706c..2ad71ba 100644 --- a/code/cogs/request.py +++ b/code/cogs/request.py @@ -1,53 +1,111 @@ import discord from discord import app_commands from discord.ext import commands +from typing import Literal -from func.radarr import get_movies, AddMovieView +from utils.content_get import get_content +from utils.content_view import AddContentView +from utils.config import ( + RADARR_HOST_URL, + RADARR_HEADERS, + RADARR_ROOT_FOLDER_PATH, + RADARR_QUALITY_PROFILE_ID, + SONARR_HOST_URL, + SONARR_HEADERS, + SONARR_ROOT_FOLDER_PATH, + SONARR_QUALITY_PROFILE_ID, +) -class Request(commands.GroupCog, name="request"): +class Request(commands.Cog): def __init__(self, bot): self.bot = bot - @app_commands.command(name="movie") - @app_commands.describe(name="Name of the movie to add") - async def request_movie(self, interaction: discord.Interaction, name: str): - "Request a movie to be added to the Radarr library" - movie_data = get_movies(name) - if movie_data == "NO RESULTS": + @app_commands.command() + @app_commands.describe(form="Are you requesting a Movie or Show?") + @app_commands.describe(name="Name of the content") + async def request( + self, + interaction: discord.Interaction, + form: Literal["Movie", "Show"], + name: str, + ) -> None: + """Request a movie or tv show to be added to the library""" + # Get matching content from relevant service + if form == "Movie": + content_data = get_content( + name, "radarr", RADARR_HOST_URL, RADARR_HEADERS + ) + else: + content_data = get_content( + name, "sonarr", SONARR_HOST_URL, SONARR_HEADERS + ) + + if content_data == "NO RESULTS": embed = discord.Embed( title="No Results", - description="No results were found for the given movie name. If you are unable to find the movie, contact an administrator to have it added manually.", - color=0xD01B86 + description=( + # fmt: off + "No results found, please try again. Here are some tips:\n\n" + "1. Double check spelling\n" + "2. Add release year to the query\n" + "3. Double check the \"Movie\" or \"Show\" option" + # fmt: on + ), + color=0xD01B86, + ) + return await interaction.response.send_message( + embed=embed, ephemeral=True ) - return await interaction.response.send_message(embed=embed, ephemeral=True) - if movie_data == "ALREADY ADDED": + if content_data == "ALREADY ADDED": embed = discord.Embed( title="Already Added", - description="The movie you are trying to add has already been added to the Radarr library.\n\nYou can check the download status of your requests movies by running the `/status` command.", - color=0xD01B86 + description=( + f"**{name}** is already added to the" + f" {'radarr' if form == 'Movie' else 'sonarr'} library. It" + " may be downloading, stalled, or not found. Check the" + " status of the content you have requested with" + " `/status`." + ), + color=0xD01B86, + ) + return await interaction.response.send_message( + embed=embed, ephemeral=True ) - return await interaction.response.send_message(embed=embed, ephemeral=True) embed = discord.Embed( title="Results Found", - description="Please select the movie you would like to add from the dropdown below.", - color=0xD01B86 + description=( + f"Please select from the top {len(content_data)} results from" + f" {'radarr' if form == 'Movie' else 'sonarr'} in the" + " dropdown below." + ), + color=0xD01B86, ) - view = AddMovieView(movie_data) - await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + # Create view with the content data and relevant service info + if form == "Movie": + view = AddContentView( + content_data, + "radarr", + RADARR_HOST_URL, + RADARR_HEADERS, + RADARR_ROOT_FOLDER_PATH, + RADARR_QUALITY_PROFILE_ID, + ) + else: + view = AddContentView( + content_data, + "sonarr", + SONARR_HOST_URL, + SONARR_HEADERS, + SONARR_ROOT_FOLDER_PATH, + SONARR_QUALITY_PROFILE_ID, + ) - @app_commands.command(name="show") - @app_commands.describe(name="Name of the show/series to add") - async def request_show(self, interaction: discord.Interaction, name: str): - "Request a show/series to be added to the Sonarr library" - embed = discord.Embed( - title="Coming Soon", - description="This feature is not yet implemented. Check back later.", - color=0xD01B86 + await interaction.response.send_message( + embed=embed, view=view, ephemeral=True ) - await interaction.response.send_message(embed=embed, ephemeral=True) async def setup(bot): diff --git a/code/cogs/status.py b/code/cogs/status.py index 7b6b463..abda84a 100644 --- a/code/cogs/status.py +++ b/code/cogs/status.py @@ -3,10 +3,13 @@ from discord import app_commands from discord.ext import commands import requests import sqlite3 -import datetime -import humanize -from global_variables import RADARR_HOST_URL, RADARR_HEADERS +from utils.config import ( + RADARR_HOST_URL, + RADARR_HEADERS, + SONARR_HOST_URL, + SONARR_HEADERS, +) class Status(commands.Cog): @@ -14,98 +17,230 @@ class Status(commands.Cog): self.bot = bot @app_commands.command() - async def status(self, interaction: discord.Interaction): - "Get the status of the movies you have requested" - # Get all the movie_ids that were requested by the user + async def status(self, interaction: discord.Interaction) -> None: + """Get the status of the movies you have requested""" db = sqlite3.connect("cordarr.db") cursor = db.cursor() cursor.execute( - "SELECT movie_id, movie_title FROM movies WHERE user_id = ?", + "SELECT title, release_year, local_id, tmdbid, tvdbid FROM" + " requests WHERE user_id = ?", (interaction.user.id,), ) - requested_movies = cursor.fetchall() - - users_movies = {} # Dictionary to store the movies that the user has requested - for movie_id, movie_title in requested_movies: - users_movies[movie_id] = movie_title - # If theres no movies, return a message saying so - if not users_movies: - embed = discord.Embed( - title="No Movies Requested", - description="You have no movies being downloaded at the moment. If you previously added a movie, it is likely that it has finished downloading. If you believe this is an error, please contact an administrator.", - color=0xD01B86 - ) - return await interaction.response.send_message(embed=embed, ephemeral=True) - # Otherwise, create the default embed to display the movies being downloaded - embed = discord.Embed( - title="Movies Requested", - description="Here are the movies you have requested that are currently being downloaded:\n", - color=0xD01B86 - ) - - # Now, we get the download status of all movies from the Radarr queue - response = requests.get( - f"{RADARR_HOST_URL}/api/v3/queue/", headers=RADARR_HEADERS - ).json() - - count = 0 - added_movie_ids = [] - for movie in response["records"]: - movie_id = movie["movieId"] - # If the movie is user requested and is being downloaded - if movie_id in users_movies.keys(): - count += 1 - added_movie_ids.append(movie_id) - if movie["status"] == "downloading": - # Humanize the download time left, or result to 'Unknown - try: - time_left = humanize.precisedelta( - datetime.datetime.strptime(movie["timeleft"], "%H:%M:%S") - - datetime.datetime.strptime("00:00:00", "%H:%M:%S"), - minimum_unit="seconds", - ) - except ValueError: - # Sometimes movies will download extremely show and therefore might - # show 'days' in the time left, so strptime appropriately - time_left = humanize.precisedelta( - datetime.datetime.strptime(movie["timeleft"], "%d.%H:%M:%S") - - datetime.datetime.strptime("00:00:00", "%H:%M:%S"), - minimum_unit="seconds", - ) - except KeyError or ValueError: - time_left = "Unknown" - - # Add all the information - embed.description += f"\n{count}. **{users_movies[movie_id]}** - Time Left: ` {time_left} `" - else: - embed.description += f"\n{count}. **{users_movies[movie_id]}** - Status: `{str(movie['status']).upper()}`" - - # If a movie wasn't found in the Radarr queue, then it has either finished downloading - # or the movie was never found for download - if len(added_movie_ids) != len(users_movies.keys()): - # Grab all of the "missing" movies to see if a movie is missing or finished downloading - response = requests.get( - f"{RADARR_HOST_URL}/api/v3/wanted/missing", headers=RADARR_HEADERS - ).json() - for movie in response["records"]: - movie_id = movie["id"] - if movie_id in users_movies.keys() and movie_id not in added_movie_ids: - count += 1 - added_movie_ids.append(movie_id) - embed.description += f"\n{count}. **{users_movies[movie_id]}** - Status: ` NOT FOUND `" - # If there are still movies that haven't been added to the embed, then they - # have finished downloading and can be removed from the database - for movie_id in users_movies.keys(): - if movie_id not in added_movie_ids: - cursor.execute( - "DELETE FROM movies WHERE user_id = ? AND movie_id = ?", - (interaction.user.id, movie_id), - ) - db.commit() + requested_content = cursor.fetchall() db.close() + # No content requested + if len(requested_content) == 0: + embed = discord.Embed( + title="No Content Requested", + description=( + "If you believe this is in error, the content you have" + " requested is likely already downloaded." + ), + color=0xD01B86, + ) + return await interaction.response.send_message( + embed=embed, ephemeral=True + ) + + # Create template embed + embed = discord.Embed( + title="Requested Content", + description=( + "Below are the movies/shows you have requested that are" + " currently being downloaded:\n" + ), + color=0xD01B86, + ) + + # Unpack the content + radarr_content_info, sonarr_content_info = self.unpack_content( + requested_content + ) + # Get the descriptions and local IDs found in queue + radarr_desc, radarr_added_ids = self.process_queue( + radarr_content_info, "radarr" + ) + sonarr_desc, sonarr_added_ids = self.process_queue( + sonarr_content_info, "sonarr" + ) + + added_ids = radarr_added_ids + sonarr_added_ids + # Get the description of content not in the queue + non_queue_desc = self.get_non_queue_content( + requested_content, added_ids, interaction.user.id + ) + + embed.description += radarr_desc + sonarr_desc + non_queue_desc + await interaction.response.send_message(embed=embed, ephemeral=True) + def unpack_content(self, requested_content: list) -> tuple: + """ + Given a list of requested content, unpack it into two dictionaries + + Args: + requested_content (list): A list of requested content + + Returns: + tuple: A tuple of two dictionaries + """ + + radarr_content_info = {} + sonarr_content_info = {} + + for content in requested_content: + title, release_year, local_id, tmdbid, tvdbid = content + if tmdbid is not None: + radarr_content_info[local_id] = { + "title": title, + "release_year": release_year, + "tmdbid": tmdbid, + } + else: + sonarr_content_info[local_id] = { + "title": title, + "release_year": release_year, + "tvdbid": tvdbid, + } + + return radarr_content_info, sonarr_content_info + + def process_queue(self, content_info: dict, service: str) -> str: + """ + Given a dictionary of requested content and "sonarr"/"radarr", process the queue + + Args: + content_info (dict): A dictionary of content information + service (str): The service to check the queue of + + Returns: + str: The description of the embed + """ + + description = "" + added_ids = [] + + queue = requests.get( + f"{RADARR_HOST_URL if service == 'radarr' else SONARR_HOST_URL}/api/v3/queue", + headers=RADARR_HEADERS if service == "radarr" else SONARR_HEADERS, + ).json() + + for download in queue["records"]: + id_str = "movieId" if service == "radarr" else "seriesId" + # If the content was requested by the user + if ( + download[id_str] in content_info.keys() + and download[id_str] not in added_ids + ): + # Append local ID + added_ids.append(download[id_str]) + # Add the download to the embed + try: + time_left = self.process_time(download["timeleft"]) + except KeyError: + time_left = "Unknown" + description += ( + f"\n**{content_info[download[id_str]]['title']} ({content_info[download[id_str]]['release_year']})**" + f" - Time Left: `{time_left}`" + ) + + return description, added_ids + + def get_non_queue_content( + self, requested_content: list, added_ids: list, user_id: int + ) -> str: + """ + Given a list of requested content and a list of added IDs, return a description of content not in the queue + + Args: + requested_content (list): A list of requested content + added_ids (list): A list of IDs that are in the queue + user_id (int): The ID of the user + + Returns: + str: A description of content not in the queue + """ + + description = "" + # For evry piece of content not in the queue, check if it has a file + for content in requested_content: + title, release_year, local_id, tmdbid, _ = content + # If not in queue + if local_id not in added_ids: + # Pull the movie data from the service + if tmdbid is not None: + data = requests.get( + f"{RADARR_HOST_URL}/api/v3/movie/{local_id}", + headers=RADARR_HEADERS, + ).json() + else: + data = requests.get( + f"{SONARR_HOST_URL}/api/v3/series/{local_id}", + headers=SONARR_HEADERS, + ).json() + + # If the movie has a file, then it has finished downloading + if data.get("hasFile", True): + # Remove from database + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "DELETE FROM requests WHERE user_id = ? AND" + " local_id = ?", + (user_id, local_id), + ) + db.commit() + db.close() + # If series and only a portion of episodes have been downloaded + if data.get("statistics").get("percentOfEpisodes"): + description += ( + f"\n**{title} ({release_year})** - Status: `NOT" + " FOUND" + f" ({int(data['statistics']['percentOfEpisodes'])}%" + " of eps.)`" + ) + # All other scenarios, download not found + else: + description += ( + f"\n**{title} ({release_year})** - Status: `NOT FOUND`" + ) + + return description + + def process_time(self, time) -> str: + """ + Given a time string, process it into a human readable format + + Args: + time (str): A string representing time + + Returns: + str: A human readable time + """ + # Split the input by either ':' or spaces + parts = time.replace(" ", ":").replace(".", ":").split(":") + + # Handle different input lengths + if len(parts) == 2: # Format: MM:SS + minutes, seconds = map(int, parts) + return f"{minutes} min. {seconds} sec." + + elif len(parts) == 3: # Format: HH:MM:SS + hours, minutes, seconds = map(int, parts) + if hours == 0: + return f"{minutes} min. {seconds} sec." + return f"{hours} hr. {minutes} min." + + elif len(parts) == 4: # Format: D:HH:MM:SS + days, hours, minutes, seconds = map(int, parts) + if days == 0: + return f"{hours} hr. {minutes} min." + return f"{days} days {hours} hr." + + else: + return "Unknown" + async def setup(bot): await bot.add_cog(Status(bot)) diff --git a/code/cogs/tree_sync.py b/code/cogs/tree_sync.py index 5050730..b84766e 100644 --- a/code/cogs/tree_sync.py +++ b/code/cogs/tree_sync.py @@ -9,7 +9,9 @@ class TreeSync(commands.Cog): @commands.command() @commands.dm_only() @commands.is_owner() - async def sync(self, ctx: commands.Context, *, guild: Object = None) -> None: + async def sync( + self, ctx: commands.Context, *, guild: Object = None + ) -> None: if not guild or guild == None: await self.bot.tree.sync() await ctx.author.send("Synced commands globally") diff --git a/code/func/radarr.py b/code/func/radarr.py deleted file mode 100644 index cc45586..0000000 --- a/code/func/radarr.py +++ /dev/null @@ -1,165 +0,0 @@ -import requests -import sqlite3 -import discord - -from global_variables import ( - RADARR_HOST_URL, - RADARR_HEADERS, - ROOT_FOLDER_PATH, - QUALITY_PROFILE_ID, -) - -""" -Add a specific movie to the Radarr library -""" - - -def get_movies(name: str): - # Remove leading/trailing whitespace and replace spaces with URL encoding - name = name.strip().replace(" ", "%20") - - # Send a request to the Radarr API to search for the movie - response = requests.get( - f"{RADARR_HOST_URL}/api/v3/movie/lookup?term={name}", headers=RADARR_HEADERS - ).json() - - if len(response) == 0: - return "NO RESULTS" - # If the movie has alreadt been added, then the added date will be - # something other than 0001-01-01T05:51:00Z - if response[0]["added"] != "0001-01-01T05:51:00Z": - return "ALREADY ADDED" - - movie_data = [] - for i in range(min(5, len(response))): - movie_data.append( - { - "title": response[i]["title"], - "year": response[i]["year"], - "tmdbId": response[i]["tmdbId"], - "description": response[i]["overview"], - } - ) - - try: - try: - movie_data[i]["remotePoster"] = response[i]["images"][0]["remoteUrl"] - except IndexError: - movie_data[i]["remotePoster"] = response[i]["images"][1]["remoteUrl"] - except IndexError: - movie_data[i]["remotePoster"] = None - - return movie_data - - -""" -Send a request to the Radarr API to add the movie -""" - - -def add_movie(tmdb_id: int): - # Get the necessary data for the movie - data = requests.get( - f"{RADARR_HOST_URL}/api/v3/movie/lookup/tmdb?tmdbId={tmdb_id}", - headers=RADARR_HEADERS, - ).json() - - movie_title = data["title"] - # Change the qualityProfileId, monitored, and rootFolderPath values - data["qualityProfileId"] = QUALITY_PROFILE_ID - data["monitored"] = True - data["rootFolderPath"] = ROOT_FOLDER_PATH - # Send the request to add the movie - response = requests.post( - f"{RADARR_HOST_URL}/api/v3/movie", headers=RADARR_HEADERS, json=data - ).json() - movie_id = response["id"] - - # Return the movie_title, movie_id - return movie_title, movie_id - - -class AddMovieView(discord.ui.View): - def __init__(self, movie_data: list, *, timeout=180.0): - super().__init__(timeout=timeout) - self.add_item(AddMovieDropdown(movie_data)) - - -class AddMovieDropdown(discord.ui.Select): - def __init__(self, movie_data: list, *, timeout=180.0): - self.movie_data = movie_data - # Create the options list to show the movie title, year, and tmdbId - options = [] - for i in range(len(movie_data)): - options.append( - discord.SelectOption( - label=f"{movie_data[i]['title']} ({movie_data[i]['year']})", - description=f"TMDB ID: {movie_data[i]['tmdbId']}", - value=i, - ) - ) - - super().__init__( - placeholder="Select from the dropdown", - options=options, - ) - - async def callback(self, interaction: discord.Interaction): - index = int(self.values[0]) - - embed = discord.Embed( - title="Is this the movie you want to add?", - description=f"**{self.movie_data[index]['title']}**\n\n{self.movie_data[index]['description']}", - color=0xD01B86, - ) - embed.set_image(url=self.movie_data[index]["remotePoster"]) - view = RequestButtonView(self.movie_data[index]["tmdbId"]) - await interaction.response.edit_message(embed=embed, view=view) - - -class RequestButtonView(discord.ui.View): - def __init__(self, tmdb_id: int, *, timeout=180.0): - super().__init__(timeout=timeout) - self.tmdb_id = tmdb_id - - @discord.ui.button(label="Request", style=discord.ButtonStyle.success) - async def request_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - # Add the movie to the Radarr library - movie_title, movie_id = add_movie(self.tmdb_id) - - # Alert the user that the movie has been added - embed = discord.Embed( - title="Movie Requested", - description=f"**{movie_title}** has been requested and will be added to the Radarr library. You can check the download status of your requested movies by running the `/status` command. Please wait ~5 minutes for Radarr to find a download for the movie.", - color=0xD01B86, - ) - await interaction.response.edit_message(embed=embed, view=None) - # Force Radarr to search for the movie - requests.post( - f"{RADARR_HOST_URL}/api/v3/command", - headers=RADARR_HEADERS, - json={"name": "MoviesSearch", "movieIds": [movie_id]}, - ) - - # Keep track of the movie for the `/status` command - db = sqlite3.connect("cordarr.db") - cursor = db.cursor() - cursor.execute( - "INSERT INTO movies VALUES (?, ?, ?)", - (interaction.user.id, movie_id, movie_title), - ) - db.commit() - db.close() - - @discord.ui.button(label="Don't Request", style=discord.ButtonStyle.danger) - async def dont_request_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - embed = discord.Embed( - title="Request Cancelled", - description="Request has been cancelled. If you would like to request a different movie, run the `/request movie` command again.", - color=0xD01B86, - ) - await interaction.response.edit_message(embed=embed, view=None) diff --git a/code/global_variables.py b/code/global_variables.py deleted file mode 100644 index 26e5558..0000000 --- a/code/global_variables.py +++ /dev/null @@ -1,82 +0,0 @@ -import configparser -import logging -from colorlog import ColoredFormatter - -log_level = logging.DEBUG -log_format = ( - " %(log_color)s%(levelname)-8s%(reset)s | %(log_color)s%(message)s%(reset)s" -) - -logging.root.setLevel(log_level) -formatter = ColoredFormatter(log_format) - -stream = logging.StreamHandler() -stream.setLevel(log_level) -stream.setFormatter(formatter) - -LOG = logging.getLogger("pythonConfig") -LOG.setLevel(log_level) -LOG.addHandler(stream) - -YES_VALUES = ["yes", "y", "true", "t", "1"] -NO_VALUES = ["no", "n", "false", "f", "0"] - -try: - with open("config.ini", "r") as f: - file_contents = f.read() -except FileNotFoundError: - config = configparser.ConfigParser() - config["REQUIRED"] = { - "BOT_TOKEN": "", - "RADARR_HOST_URL": "http://", - "RADARR_API_KEY": "", - "ROOT_FOLDER_PATH": "", - "QUALITY_PROFILE_ID": "", - "ENABLE_JELLYFIN_TEMP_ACCOUNTS": "", - } - - config["JELLYFIN_ACCOUNTS"] = { - "JELLYFIN_URL": "", - "JELLYFIN_API_KEY": "", - "ACCOUNT_TIME": "", - "SIMPLE_PASSWORDS": "", - } - - with open("config.ini", "w") as configfile: - config.write(configfile) - - LOG.error("Configuration file `config.ini` has been generated. Please fill out all of the necessary information. Refer to the docs for information on what a specific configuration option is.") - exit() - -config = configparser.ConfigParser() -config.read_string(file_contents) - -BOT_TOKEN = config["REQUIRED"]["BOT_TOKEN"] -RADARR_HOST_URL = config["REQUIRED"]["RADARR_HOST_URL"] -RADARR_API_KEY = config["REQUIRED"]["RADARR_API_KEY"] -ROOT_FOLDER_PATH = config["REQUIRED"]["ROOT_FOLDER_PATH"] -QUALITY_PROFILE_ID = config["REQUIRED"]["QUALITY_PROFILE_ID"] - -if config["REQUIRED"]["ENABLE_JELLYFIN_TEMP_ACCOUNTS"].lower() in YES_VALUES: - ENABLE_JELLYFIN_TEMP_ACCOUNTS = True -else: - ENABLE_JELLYFIN_TEMP_ACCOUNTS = False - -JELLYFIN_URL = config["JELLYFIN_ACCOUNTS"]["JELLYFIN_URL"] -JELLYFIN_API_KEY = config["JELLYFIN_ACCOUNTS"]["JELLYFIN_API_KEY"] -ACCOUNT_TIME = int(config["JELLYFIN_ACCOUNTS"]["ACCOUNT_TIME"]) - -RADARR_HEADERS = { - "Content-Type": "application/json", - "X-Api-Key": RADARR_API_KEY -} - -JELLYFIN_HEADERS = { - "Content-Type": "application/json", - "X-Emby-Token": JELLYFIN_API_KEY, -} - -if config["JELLYFIN_ACCOUNTS"]["SIMPLE_PASSWORDS"].lower() in YES_VALUES: - SIMPLE_PASSWORDS = True -else: - SIMPLE_PASSWORDS = False \ No newline at end of file diff --git a/code/utils/config.py b/code/utils/config.py new file mode 100644 index 0000000..491677d --- /dev/null +++ b/code/utils/config.py @@ -0,0 +1,298 @@ +import jsonschema +import validators +import yaml +import sys +import logging +import requests +import sqlite3 +from colorlog import ColoredFormatter + + +log_level = logging.DEBUG +log_format = ( + " %(log_color)s%(levelname)-8s%(reset)s |" + " %(log_color)s%(message)s%(reset)s" +) + +logging.root.setLevel(log_level) +formatter = ColoredFormatter(log_format) + +stream = logging.StreamHandler() +stream.setLevel(log_level) +stream.setFormatter(formatter) + +LOG = logging.getLogger("pythonConfig") +LOG.setLevel(log_level) +LOG.addHandler(stream) + +BOT_TOKEN = None + +RADARR_ENABLED = False +RADARR_HOST_URL = None +RADARR_HEADERS = None +RADARR_ROOT_FOLDER_PATH = None +RADARR_QUALITY_PROFILE_ID = None + +SONARR_ENABLED = False +SONARR_HOST_URL = None +SONARR_HEADERS = None +SONARR_ROOT_FOLDER_PATH = None +SONARR_QUALITY_PROFILE_ID = None + +JELLYFIN_ENABLED = False +JELLYFIN_URL = None +JELLYFIN_HEADERS = None +ACCOUNT_TIME = None +SIMPLE_PASSWORDS = False + +schema = { + "type": "object", + "properties": { + "bot_info": { + "type": "object", + "properties": { + "bot_token": {"type": "string"}, + }, + "required": ["bot_token"], + }, + "radarr": { + "type": "object", + "properties": { + "host_url": {"type": "string"}, + "api_key": {"type": "string"}, + "root_folder_path": {"type": "string"}, + "quality_profile_id": {"type": "integer"}, + }, + "required": [ + "host_url", + "api_key", + "root_folder_path", + ], + }, + "sonarr": { + "type": "object", + "properties": { + "host_url": {"type": "string"}, + "api_key": {"type": "string"}, + "root_folder_path": {"type": "string"}, + "quality_profile_id": {"type": "integer"}, + }, + "required": [ + "host_url", + "api_key", + "root_folder_path", + ], + }, + "jellyfin": { + "type": "object", + "properties": { + "url": {"type": "string"}, + "api_key": {"type": "string"}, + "account_time": {"type": "integer"}, + }, + "required": ["url", "api_key", "account_time"], + }, + }, + "required": ["bot_info", "radarr", "sonarr"], +} + + +def load_config() -> None: + """ + Load DB, then load and validate the config file + If the file does not exist, generate it + """ + database_setup() + try: + with open("config.yaml", "r") as f: + contents = f.read() + validate_config(contents) + + except FileNotFoundError: + with open("config.yaml", "w") as f: + f.write( + """ +bot_info: + bot_token: YOUR_BOT_TOKEN + +radarr: + host_url: RADARR_URL + api_key: RADARR_API_KEY + root_folder_path: RADARR_ROOT_FOLDER_PATH + quality_profile_id: RADARR_QUALITY_PROFILE_ID + +sonarr: + host_url: SONARR_URL + api_key: SONARR_API_KEY + root_folder_path: SONARR_ROOT_FOLDER_PATH + quality_profile_id: SONARR_QUALITY_PROFILE_ID + +jellyfin: + url: JELLYFIN_URL + api_key: JELLYFIN_API_KEY + account_time: ACCOUNT_ACTIVE_TIME + simple_passwords: SIMPLE_OR_COMPLEX_PASSWORDS + """ + ) + + sys.exit( + LOG.critical( + "Config file `config.yaml` has been generated. Input necessary" + " fields and restart. Refer to README for help!" + ) + ) + + +def database_setup() -> None: + """ + Create the database if it does not exist + """ + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "CREATE TABLE IF NOT EXISTS requests (title TEXT, release_year TEXT," + " local_id INTEGER, tmdbid INTEGER, tvdbid INTEGER, user_id INTEGER)" + ) + cursor.execute( + "CREATE TABLE IF NOT EXISTS jellyfin_accounts (user_id INTEGER," + " jellyfin_user_id INTEGER, deletion_time DATETIME)" + ) + db.commit() + db.close() + + +def validate_config(contents) -> None: + """ + Validate the contents of the config file and assign variables + + Args: + contents (str): The contents of the config file + """ + global BOT_TOKEN, RADARR_HOST_URL, RADARR_ENABLED, RADARR_HEADERS, RADARR_ROOT_FOLDER_PATH, RADARR_QUALITY_PROFILE_ID, SONARR_ENABLED, SONARR_HOST_URL, SONARR_HEADERS, SONARR_ROOT_FOLDER_PATH, SONARR_QUALITY_PROFILE_ID, JELLYFIN_ENABLED, JELLYFIN_URL, JELLYFIN_HEADERS, ACCOUNT_TIME, SIMPLE_PASSWORDS + + config = yaml.safe_load(contents) + + try: + jsonschema.validate(config, schema) + except jsonschema.ValidationError as e: + sys.exit(LOG.critical(f"Error in config.yaml: {e.message}")) + + # + # Begin validating values and assigning variables + # + + BOT_TOKEN = config["bot_info"]["bot_token"] + + if "radarr" in config: + if not validators.url(config["radarr"]["host_url"]): + sys.exit( + LOG.critical( + "Error in config.yaml: Invalid URL for Radarr host" + ) + ) + else: + RADARR_HOST_URL = config["radarr"]["host_url"] + + RADARR_HEADERS = { + "Content-Type": "application/json", + "X-Api-Key": config["radarr"]["api_key"], + } + RADARR_ROOT_FOLDER_PATH = config["radarr"]["root_folder_path"] + # set radarr quality profile id + RADARR_QUALITY_PROFILE_ID = validate_profile( + "radarr", RADARR_HOST_URL, RADARR_HEADERS, config + ) + RADARR_ENABLED = True + + if "sonarr" in config: + if not validators.url(config["sonarr"]["host_url"]): + sys.exit( + LOG.critical( + "Error in config.yaml: Invalid URL for Sonarr host" + ) + ) + else: + SONARR_HOST_URL = config["sonarr"]["host_url"] + + SONARR_HEADERS = { + "Content-Type": "application/json", + "X-Api-Key": config["sonarr"]["api_key"], + } + SONARR_ROOT_FOLDER_PATH = config["sonarr"]["root_folder_path"] + # set sonarr quality profile id + SONARR_QUALITY_PROFILE_ID = validate_profile( + "sonarr", SONARR_HOST_URL, SONARR_HEADERS, config + ) + SONARR_ENABLED = True + + if "jellyfin" in config: + if not validators.url(config["jellyfin"]["url"]): + LOG.critical( + "Error in config.yaml: Invalid URL for Jellyfin - account" + " creation disabled" + ) + else: + JELLYFIN_URL = config["jellyfin"]["url"] + + JELLYFIN_HEADERS = { + "Content-Type": "application/json", + "X-Emby-Token": config["jellyfin"]["api_key"], + } + ACCOUNT_TIME = config["jellyfin"]["account_time"] + SIMPLE_PASSWORDS = config["jellyfin"] + JELLYFIN_ENABLED = True + + +def validate_profile( + service: str, url: str, headers: dict, config: dict +) -> int: + """ + Validate the quality profile ID for the given service + + Args: + service (str): The service to validate the profile for + url (str): The URL of the service + headers (dict): The headers for the request + config (dict): The config file + + Returns: + int: The quality profile ID + """ + profiles = requests.get(f"{url}/api/v3/qualityProfile", headers=headers) + + if profiles.status_code != 200: + LOG.critical( + f"Error in config.yaml: Unable to get {service} quality profiles." + f" API Key invalid or incorrect {service} URL" + ) + + else: + profiles = profiles.json() + # If ID is not given, list options + if "quality_profile_id" not in config[f"{service}"]: + LOG.critical( + "Error in config.yaml: No quality profile ID provided for" + f" {service}. Look below for a list of your available" + " profiles:" + ) + + for profile in profiles: + LOG.info(f"ID: {profile['id']} | Name: {profile['name']}") + + # ID is given, validate + else: + quality_profile_id = config[f"{service}"]["quality_profile_id"] + if quality_profile_id not in [ + profile["id"] for profile in profiles + ]: + LOG.critical( + f"Error in config.yaml: Invalid {service} quality profile" + " ID. Look below for your available profiles:" + ) + + for profile in profiles: + LOG.info(f"ID: {profile['id']} | Name: {profile['name']}") + sys.exit() + # Everything valid, assign + else: + return quality_profile_id diff --git a/code/utils/content_add.py b/code/utils/content_add.py new file mode 100644 index 0000000..fda43af --- /dev/null +++ b/code/utils/content_add.py @@ -0,0 +1,57 @@ +import requests + + +def add_content( + content_info: dict, + service: str, + host: str, + headers: str, + folder_path: str, + profile_id: str, +): + """ + Add content to Sonarr or Radarr + + Args: + content_info (dict): The content information + service (str): The service to add the content to + host (str): The host URL + headers (str): The headers for the request + folder_path (str): The folder path to download the content to + profile_id (str): The profile ID to download the content in + + Returns: + str: The ID of the content or False + """ + # Get the content data based on ID + data = requests.get( + url=( + f"{host}/api/v3/movie/lookup/tmdb?tmdbId={content_info['contentId']}" + if service == "radarr" + else f"{host}/api/v3/series/lookup?term=tvdb:{content_info['contentId']}" + ), + headers=headers, + ).json()[0] + + data["monitored"] = True + data["qualityProfileId"] = profile_id + data["rootFolderPath"] = folder_path + # Search for the content on add + data["addOptions"] = { + ( + "searchForMovie" + if service == "radarr" + else "searchForMissingEpisodes" + ): True + } + # Send the request to add the content + response = requests.post( + f"{host}/api/v3/{'movie' if service == 'radarr' else 'series'}", + headers=headers, + json=data, + ) + + if response.status_code == 201: + return response.json()["id"] + else: + return False diff --git a/code/utils/content_get.py b/code/utils/content_get.py new file mode 100644 index 0000000..1b724e5 --- /dev/null +++ b/code/utils/content_get.py @@ -0,0 +1,59 @@ +import requests + + +def get_content( + query: str, + service: str, + host: str, + headers: str, +): + """ + Fetch the top 5 results from the service given a query + + Args: + query (str): The query to search for + service (str): The service to search in + host (str): The host URL + headers (str): The headers for the request + + Returns: + list: A list containing content_info dict + str: NO RESULTS + str: ALREADY ADDED + """ + query = query.strip().replace(" ", "%20") + # Search for matching content + results = requests.get( + f"{host}/api/v3/{'movie' if service == 'radarr' else 'series'}/lookup?term={query}", + headers=headers, + ).json() + + if len(results) == 0: + return "NO RESULTS" + # If already added to library + if results[0]["added"] != "0001-01-01T05:51:00Z": + return "ALREADY ADDED" + + # Add info for top results + content_info = [] + for i in range(min(5, len(results))): + content_info.append( + { + "title": results[i]["title"], + "year": results[i]["year"], + "contentId": results[i][ + f"{'tmdbId' if service == 'radarr' else 'tvdbId'}" + ], + "description": results[i]["overview"], + } + ) + + # Add remotePoster field, set None if not available + try: + content_info[i]["remotePoster"] = results[i]["images"][0][ + "remoteUrl" + ] + except IndexError: + content_info[i]["remotePoster"] = None + + return content_info diff --git a/code/utils/content_view.py b/code/utils/content_view.py new file mode 100644 index 0000000..e8df29c --- /dev/null +++ b/code/utils/content_view.py @@ -0,0 +1,203 @@ +import discord +import sqlite3 + +from utils.content_add import add_content + +""" +View to add the Dropdown menu +""" + + +class AddContentView(discord.ui.View): + def __init__( + self, + content_data: list, + service: str, + host: str, + header: str, + path: str, + profile: int, + *, + timeout=180.0, + ): + super().__init__(timeout=timeout) + # Add the dropdown + self.add_item( + AddContentDropdown( + content_data, service, host, header, path, profile + ) + ) + + +""" +Dropdown containing the top 5 content results +""" + + +class AddContentDropdown(discord.ui.Select): + def __init__( + self, + content_data: list, + service: str, + host: str, + header: str, + path: str, + profile: str, + *, + timeout=180.0, + ): + self.content_data = content_data + self.service = service + self.host = host + self.header = header + self.path = path + self.profile = profile + options = [] + for i in range(len(content_data)): + options.append( + discord.SelectOption( + label=( + f"{content_data[i]['title']} ({content_data[i]['year']})" + ), + description=f"Relevant ID: {content_data[i]['contentId']}", + value=str(i), + ) + ) + + super().__init__( + placeholder="Select from the dropdown", + options=options, + ) + + # Once an option has been selected + async def callback(self, interaction: discord.Interaction): + # Index of selected option + index = int(self.values[0]) + + # Add selected contents info to an embed + embed = discord.Embed( + title="Is the the content you want to add?", + description=( + f"**Title**: {self.content_data[index]['title']} | " + f"**Year**: {self.content_data[index]['year']}\n\n" + f"**Description**: {self.content_data[index]['description']}" + ), + color=0xD01B86, + ) + embed.set_image(url=self.content_data[index]["remotePoster"]) + # Change the view to the Request/Don't Request buttons + view = RequestButtonView( + self.content_data[index], + self.service, + self.host, + self.header, + self.path, + self.profile, + ) + await interaction.response.edit_message(embed=embed, view=view) + + +""" +View containing the "Request" and "Don't Request" buttons +""" + + +class RequestButtonView(discord.ui.View): + def __init__( + self, + content_info: dict, + service: str, + host: str, + headers: str, + path: str, + profile: int, + *, + timeout=180.0, + ): + super().__init__(timeout=timeout) + self.content_info = content_info + self.service = service + self.host = host + self.headers = headers + self.path = path + self.profile = profile + + @discord.ui.button(label="Request", style=discord.ButtonStyle.success) + async def request_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + # Add the content to the relevant library + local_id = add_content( + self.content_info, + self.service, + self.host, + self.headers, + self.path, + self.profile, + ) + + # Alert the user that the content has been added + if local_id: + embed = discord.Embed( + title="Content Added", + description=( + f"**{self.content_info['title']}** has been added to the" + f" {self.service} library. Check the status of your" + " requested content with `/status`." + ), + color=0xD01B86, + ) + await interaction.response.send_message(embed=embed) + # Alert the user that the content failed to be added + else: + embed = discord.Embed( + title="Failed to Add Content", + description=( + "An error occured when attempting to add" + f" **{self.content_info['title']}** to the" + f" {self.service} library." + ), + ) + return await interaction.response.send_message(embed=embed) + + # Keep track of the requests for the `/status` command + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "INSERT INTO requests (title, release_year, local_id, tmdbid," + " tvdbid, user_id) VALUES (?, ?, ?, ?, ?, ?)", + ( + self.content_info["title"], + self.content_info["year"], + local_id, + ( + self.content_info["contentId"] + if self.service == "radarr" + else None + ), + ( + None + if self.service == "radarr" + else self.content_info["contentId"] + ), + interaction.user.id, + ), + ) + db.commit() + db.close() + + @discord.ui.button(label="Don't Request", style=discord.ButtonStyle.danger) + async def dont_request_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + embed = discord.Embed( + title="Request Cancelled", + description=( + "Request has been cancelled. If you would like to request a" + " different" + f" {'movie' if self.service == 'radarr' else 'show'}, run the" + " `/request` command again." + ), + color=0xD01B86, + ) + await interaction.response.send_message(embed=embed) diff --git a/code/func/jellyfin.py b/code/utils/jellyfin_create.py similarity index 64% rename from code/func/jellyfin.py rename to code/utils/jellyfin_create.py index f31ec04..08c5230 100644 --- a/code/func/jellyfin.py +++ b/code/utils/jellyfin_create.py @@ -1,31 +1,45 @@ import datetime import requests import random -import string import sqlite3 from wonderwords import RandomWord +from string import ascii_lowercase, digits -from global_variables import JELLYFIN_URL, JELLYFIN_HEADERS, ACCOUNT_TIME, SIMPLE_PASSWORDS - -""" -Create a new Jellyfin account for the user and return the username and password -""" +from utils.config import ( + JELLYFIN_URL, + JELLYFIN_HEADERS, + ACCOUNT_TIME, + SIMPLE_PASSWORDS, +) def create_jellyfin_account(user_id): + """ + Create a new Jellyfin account for the user and return the username and password + + Args: + user_id (int): Discord user ID to create the account for + + Returns: + tuple: The username and password of the new Jellyfin account + """ + # Create username/password username = RandomWord().word(word_min_length=5, word_max_length=5) if SIMPLE_PASSWORDS: password = RandomWord().word(word_min_length=5, word_max_length=10) else: - password = "".join(random.choices(string.ascii_lowercase + string.digits, k=15)) + password = "".join(random.choices(ascii_lowercase + digits, k=15)) - deletion_time = datetime.datetime.now() + datetime.timedelta(hours=ACCOUNT_TIME) + deletion_time = datetime.datetime.now() + datetime.timedelta( + minutes=ACCOUNT_TIME * 60 + ) # Create the new Jellyfin account request_1 = requests.post( f"{JELLYFIN_URL}/Users/New", headers=JELLYFIN_HEADERS, json={"Name": username, "Password": password}, ) + if request_1.status_code != 200: return False @@ -56,36 +70,11 @@ def create_jellyfin_account(user_id): db = sqlite3.connect("cordarr.db") cursor = db.cursor() cursor.execute( - "INSERT INTO jellyfin_accounts (user_id, jellyfin_user_id, deletion_time) VALUES (?, ?, ?)", + "INSERT INTO jellyfin_accounts (user_id, jellyfin_user_id," + " deletion_time) VALUES (?, ?, ?)", (user_id, jellyfin_user_id, deletion_time), ) db.commit() db.close() return username, password - - -""" -Delete a specific Jellyfin account and return True/False -""" - - -def delete_jellyfin_account(jellyfin_user_id): - request = requests.delete( - f"{JELLYFIN_URL}/Users/{jellyfin_user_id}", - headers=JELLYFIN_HEADERS, - ) - # If 204 - account deleted - # If 404 - account not found - # Either way, remove account from database - if request.status_code in (404, 204): - db = sqlite3.connect("cordarr.db") - cursor = db.cursor() - cursor.execute( - "DELETE FROM jellyfin_accounts WHERE jellyfin_user_id = ?", - (jellyfin_user_id,), - ) - db.commit() - db.close() - return True - return False \ No newline at end of file diff --git a/code/utils/jellyfin_delete.py b/code/utils/jellyfin_delete.py new file mode 100644 index 0000000..6164e40 --- /dev/null +++ b/code/utils/jellyfin_delete.py @@ -0,0 +1,38 @@ +import datetime +import sqlite3 +import requests + +from utils.config import JELLYFIN_URL, JELLYFIN_HEADERS + + +def delete_accounts(): + """ + Delete Jellyfin accounts that have passed their deletion time + """ + # Get all expired Jellyfin accounts + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "SELECT jellyfin_user_id FROM jellyfin_accounts WHERE" + " deletion_time < ?", + (datetime.datetime.now(),), + ) + jellyfin_user_ids = cursor.fetchall() + + # Delete the Jellyfin accounts + for jellyfin_user_id in jellyfin_user_ids: + request = requests.delete( + f"{JELLYFIN_URL}/Users/{jellyfin_user_id[0]}", + headers=JELLYFIN_HEADERS, + ) + # If 204 - account deleted + # If 404 - account not found + # Either way, remove account from database + if request.status_code in (404, 204): + cursor.execute( + "DELETE FROM jellyfin_accounts WHERE jellyfin_user_id = ?", + (jellyfin_user_id,), + ) + + db.commit() + db.close() diff --git a/code/validate_config.py b/code/validate_config.py deleted file mode 100644 index f3cadd0..0000000 --- a/code/validate_config.py +++ /dev/null @@ -1,235 +0,0 @@ -import configparser -import sqlite3 -import requests -from global_variables import LOG, YES_VALUES, NO_VALUES - -""" -Validate all of the options passed into the config.ini file -""" - - -def validate_config(file_contents): - config = configparser.ConfigParser() - config.read_string(file_contents) - - errors = 0 - - try: - # Validate BOT_TOKEN - if not config["REQUIRED"]["BOT_TOKEN"]: - LOG.error("BOT_TOKEN has not been set.") - errors += 1 - - # Validate RADARR_HOST_URL - if not config["REQUIRED"]["RADARR_HOST_URL"]: - LOG.error("RADARR_HOST_URL has not been set.") - errors += 1 - - # Validate RADARR_API_KEY - if not config["REQUIRED"]["RADARR_API_KEY"]: - LOG.error("RADARR_API_KEY has not been set.") - errors += 1 - - radarr_headers = { - "Content-Type": "application/json", - "X-Api-Key": config["REQUIRED"]["RADARR_API_KEY"], - } - - # Make sure connection to Radarr API can be established - try: - requests.get( - config["REQUIRED"]["RADARR_HOST_URL"], headers=radarr_headers - ) - except requests.exceptions.ConnectionError: - LOG.error( - "Could not connect to Radarr API. Please check your" - " RADARR_HOST_URL and RADARR_API_KEY" - ) - errors += 1 - - # Validate ROOT_FOLDER_PATH - if not config["REQUIRED"]["ROOT_FOLDER_PATH"]: - LOG.error("ROOT_FOLDER_PATH has not been set.") - errors += 1 - - # Validate QUALITY_PROFILE_ID - data = requests.get( - f'{config["REQUIRED"]["RADARR_HOST_URL"]}/api/v3/qualityprofile', - headers=radarr_headers, - ).json() - all_ids = [] - for entry in data: - all_ids.append(str(entry["id"])) - - if ( - not config["REQUIRED"]["QUALITY_PROFILE_ID"] - or config["REQUIRED"]["QUALITY_PROFILE_ID"] not in all_ids - ): - available_ids = {} - for entry in data: - available_ids[str(entry["id"])] = entry["name"] - - LOG.info("Available QUALITY_PROFILE_IDs:") - for key, value in available_ids.items(): - LOG.info(f"ID: {key} - Name: {value}") - - LOG.error( - "Empty or invalid QUALITY_PROFILE_ID passed. Pass one of the" - " valid IDs which are now logged above." - ) - errors += 1 - - # Validate ENABLE_JELLYFIN_TEMP_ACCOUNTS - if not config["REQUIRED"]["ENABLE_JELLYFIN_TEMP_ACCOUNTS"]: - LOG.error("ENABLE_JELLYFIN_TEMP_ACCOUNTS has not been set.") - errors += 1 - - else: - # Validate the value of ENABLE_JELLYFIN_TEMP_ACCOUNTS - if ( - config["REQUIRED"]["ENABLE_JELLYFIN_TEMP_ACCOUNTS"].lower() - not in YES_VALUES + NO_VALUES - ): - LOG.error( - "Invalid value passed to ENABLE_JELLYFIN_TEMP_ACCOUNTS." - " Pass a true/false value." - ) - errors += 1 - - if ( - config["REQUIRED"]["ENABLE_JELLYFIN_TEMP_ACCOUNTS"].lower() - in YES_VALUES - ): - # Validate JELLYFIN_URL - if not config["JELLYFIN_ACCOUNTS"]["JELLYFIN_URL"]: - LOG.error( - "Empty URL passed to JELLYFIN_URL. Pass a valid URL" - " (e.g. http://localhost:8096)" - ) - errors += 1 - # Validate JELLYFIN_API_KEY - if not config["JELLYFIN_ACCOUNTS"]["JELLYFIN_API_KEY"]: - LOG.error( - "Empty JELLYFIN_API_KEY passed. Create a Jellyfin API" - " key in your Jellyfin dashboard and pass it here." - ) - errors += 1 - # Validate ACCOUNT_TIME - if not config["JELLYFIN_ACCOUNTS"]["ACCOUNT_TIME"]: - LOG.error( - "Empty ACCOUNT_TIME passed. Pass a valid time in the" - " format of HH:MM:SS (e.g. 00:30:00)" - ) - errors += 1 - try: - time = int(config["JELLYFIN_ACCOUNTS"]["ACCOUNT_TIME"]) - except ValueError: - LOG.error( - "Invalid value passed to ACCOUNT_TIME. Pass a valid" - " integer value (e.g. 24)" - ) - errors += 1 - # Validate SIMPLE_PASSWORDS - if not config["JELLYFIN_ACCOUNTS"]["SIMPLE_PASSWORDS"]: - LOG.error( - "Empty SIMPLE_PASSWORDS passed. Pass a true/false" - " value." - ) - errors += 1 - else: - if ( - config["JELLYFIN_ACCOUNTS"]["SIMPLE_PASSWORDS"].lower() - not in YES_VALUES + NO_VALUES - ): - LOG.error( - "Invalid value passed to SIMPLE_PASSWORDS. Pass a" - " true/false value." - ) - errors += 1 - - # Make sure connection to Jellyfin API can be established - jellyfin_headers = { - "Content-Type": "application/json", - "Authorization": ( - 'MediaBrowser Client="other", device="CordArr",' - ' DeviceId="cordarr-device-id", Version="0.0.0",' - f' Token="{config["JELLYFIN_ACCOUNTS"]["JELLYFIN_API_KEY"]}"' - ), - } - - response = requests.get( - f"{config['JELLYFIN_ACCOUNTS']['JELLYFIN_URL']}/Users", - headers=jellyfin_headers, - ) - if response.status_code != 200: - LOG.error( - "Could not connect to Jellyfin API. Please check your" - " JELLYFIN_URL and JELLYFIN_API_KEY" - ) - errors += 1 - - if errors > 0: - LOG.info( - f"Found {errors} error(s) in the configuration file. Please" - " fix them before restarting the application." - ) - exit() - - except KeyError: - LOG.critical( - "You are missing at least one of the configuration options in your" - " config.ini file. In order to regenerate all options, delete the" - " config.ini file and restart the application." - ) - exit() - - -""" -This method is called before starting the application - to make and validate the configuration -""" - - -def create_config(): - # While here, we can begin by making the database - db = sqlite3.connect("cordarr.db") - cursor = db.cursor() - cursor.execute( - "CREATE TABLE IF NOT EXISTS movies (user_id, movie_id, movie_title)" - ) - cursor.execute( - "CREATE TABLE IF NOT EXISTS jellyfin_accounts (user_id," - " jellyfin_user_id, deletion_time, PRIMARY KEY (user_id))" - ) - db.commit() - db.close() - - # Attempt to open and validate the configuration file - try: - with open("config.ini", "r") as config: - file_contents = config.read() - validate_config(file_contents) - - except FileNotFoundError: - try: - with open("/data/config.ini", "r") as config: - file_contents = config.read() - validate_config(file_contents) - - except FileNotFoundError: - # Create the config.ini file - config = configparser.ConfigParser() - config["REQUIRED"] = { - "BOT_TOKEN": "", - "RADARR_HOST_URL": "http://", - "RADARR_API_KEY": "", - "ROOT_FOLDER_PATH": "", - "QUALITY_PROFILE_ID": "", - "ENABLE_JELLYFIN_TEMP_ACCOUNTS": "", - } - - config["JELLYFIN_ACCOUNTS"] = { - "JELLYFIN_URL": "", - "JELLYFIN_API_KEY": "", - "ACCOUNT_TIME": "", - "SIMPLE_PASSWORDS": "", - } diff --git a/requirements.txt b/requirements.txt index fcab068..fa63860 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ colorlog==6.8.2 -discord.py==2.3.2 requests==2.32.0 humanize==4.9.0 -wonderwords==2.2.0 \ No newline at end of file +wonderwords==2.2.0 + +# validators +# jsonschema +# pyyaml +# discord py \ No newline at end of file