diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ad0889 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +config.ini +cordarr.db +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 4e34e18..cfd1fbe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ -# CordArr - Request new movies to Radarr and create temporary Jellyfin account through Discord commands. +

+
+ Guava Image +
+ CordArr
+

+ +

+ Control your Radarr/Sonarr library and create Jellyfin accounts in Discord +

+ +

+ + discord.py + + + Code Style: Black + + + + +

+ +# Overview + +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. + +# 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. + +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 + +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 + +
+
+ +If you have any questions, feel free to email at [contact@pkrm.dev](mailto:contact@pkrm.dev). Thank you for checking out Guava, and happy coding. \ No newline at end of file diff --git a/code/bot.py b/code/bot.py new file mode 100644 index 0000000..4b3d96d --- /dev/null +++ b/code/bot.py @@ -0,0 +1,51 @@ +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 global_variables import LOG, BOT_TOKEN + + +class MyBot(commands.Bot): + def __init__(self): + super().__init__( + command_prefix="#", + intents=discord.Intents.default(), + ) + + async def setup_hook(self): + create_config() + delete_old_temp_accounts.start() + for ext in os.listdir("./code/cogs"): + if ext.endswith(".py"): + await self.load_extension(f"cogs.{ext[:-3]}") + + +bot = MyBot() +bot.remove_command("help") + + +@bot.event +async def on_ready(): + LOG.info(f"{bot.user} has connected to Discord.") + + +@tasks.loop(seconds=60) +async def delete_old_temp_accounts(): + # Delete all of the temporary Jellyfin accounts that have passed + # their expiration time + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "DELETE FROM jellyfin_accounts WHERE deletion_time < ?", + (datetime.datetime.now(),), + ) + db.commit() + db.close() + + +if __name__ == "__main__": + bot.run(BOT_TOKEN) diff --git a/code/cogs/error.py b/code/cogs/error.py new file mode 100644 index 0000000..2752400 --- /dev/null +++ b/code/cogs/error.py @@ -0,0 +1,27 @@ +import discord +from discord.ext import commands +from discord import app_commands + + +class slash_handlers(commands.Cog): + def __init__(self, bot): + self.bot = bot + bot.tree.on_error = self.on_error + + async def on_error(self, interaction: discord.Interaction, error): + if ( + isinstance(error, app_commands.CheckFailure) + and interaction.command.name == "newaccount" + ): + 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 + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + raise error + + +async def setup(bot: commands.Bot): + await bot.add_cog(slash_handlers(bot)) diff --git a/code/cogs/newaccount.py b/code/cogs/newaccount.py new file mode 100644 index 0000000..db5abd0 --- /dev/null +++ b/code/cogs/newaccount.py @@ -0,0 +1,59 @@ +import discord +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 + + +class NewAccount(commands.Cog): + def __init__(self, bot): + 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" + # 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,) + ) + if cursor.fetchone(): + 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 + ) + 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 + ) + 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 + ) + await interaction.user.send(embed=embed) + 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 + ) + return await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot): + await bot.add_cog(NewAccount(bot)) diff --git a/code/cogs/request.py b/code/cogs/request.py new file mode 100644 index 0000000..efd6c9d --- /dev/null +++ b/code/cogs/request.py @@ -0,0 +1,56 @@ +import discord +from discord import app_commands +from discord.ext import commands + +from func.radarr import get_movies, AddMovieView + + +class Request(commands.GroupCog, name="request"): + 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" + get_movies_response = get_movies(name) + if get_movies_response == "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 + ) + return await interaction.response.send_message(embed=embed, ephemeral=True) + + if get_movies_response == "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 + ) + return await interaction.response.send_message(embed=embed, ephemeral=True) + + movies, tmdb_ids = get_movies_response + + embed = discord.Embed( + title="Results Found", + description="Please select the movie you would like to add from the dropdown below.", + color=0xD01B86 + ) + view = AddMovieView(movies, tmdb_ids) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + @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, ephemeral=True) + + +async def setup(bot): + await bot.add_cog(Request(bot)) diff --git a/code/cogs/status.py b/code/cogs/status.py new file mode 100644 index 0000000..7b6b463 --- /dev/null +++ b/code/cogs/status.py @@ -0,0 +1,111 @@ +import discord +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 + + +class Status(commands.Cog): + def __init__(self, bot): + 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 + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "SELECT movie_id, movie_title FROM movies 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() + db.close() + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot): + await bot.add_cog(Status(bot)) diff --git a/code/cogs/tree_sync.py b/code/cogs/tree_sync.py new file mode 100644 index 0000000..5050730 --- /dev/null +++ b/code/cogs/tree_sync.py @@ -0,0 +1,33 @@ +from discord.ext import commands +from discord import Object + + +class TreeSync(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command() + @commands.dm_only() + @commands.is_owner() + 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") + return + + elif guild != None: + self.bot.tree.copy_global_to(guild=guild) + await self.bot.tree.sync(guild=guild) + + await ctx.author.send(f"Synced the tree to 1 test guild.") + + @sync.error + async def error_sync(self, ctx, error): + if isinstance(error, commands.errors.PrivateMessageOnly): + pass + else: + await ctx.author.send("That is not a valid guild ID") + + +async def setup(bot): + await bot.add_cog(TreeSync(bot)) diff --git a/code/func/jellyfin.py b/code/func/jellyfin.py new file mode 100644 index 0000000..d434c17 --- /dev/null +++ b/code/func/jellyfin.py @@ -0,0 +1,64 @@ +import datetime +import requests +import random +import string +import sqlite3 + +from global_variables import JELLYFIN_URL, JELLYFIN_HEADERS, ACCOUNT_TIME + +""" +Create a new Jellyfin account for the user and return the username and password +""" + + +def create_jellyfin_account(user_id): + username = "".join(random.choices(string.ascii_lowercase + string.digits, k=5)) + password = "".join(random.choices(string.ascii_lowercase + string.digits, k=15)) + + deletion_time = datetime.datetime.now() + datetime.timedelta(hours=ACCOUNT_TIME) + # 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 + + # Get the user ID of the new account + jellyfin_user_id = request_1.json()["Id"] + # Get the account policy and make edits + request_2 = requests.get( + f"{JELLYFIN_URL}/Users/{jellyfin_user_id}", headers=JELLYFIN_HEADERS + ) + if request_2.status_code != 200: + return False + + account_policy = request_2.json() + account_policy["Policy"]["SyncPlayAccess"] = "JoinGroups" + account_policy["Policy"]["EnableContentDownloading"] = False + account_policy["Policy"]["InvalidLoginAttemptCount"] = 3 + account_policy["Policy"]["MaxActiveSessions"] = 1 + # Update the user with the newly edited policy + request_3 = requests.post( + f"{JELLYFIN_URL}/Users?userId={jellyfin_user_id}", + headers=JELLYFIN_HEADERS, + json=account_policy, + ) + if request_3.status_code != 204: + print(request_3.json()) + print(request_3.status_code) + print("BROKEN AT REQUEST 3") + return False + + # Add the information to the database + db = sqlite3.connect("cordarr.db") + cursor = db.cursor() + cursor.execute( + "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 diff --git a/code/func/radarr.py b/code/func/radarr.py new file mode 100644 index 0000000..463266c --- /dev/null +++ b/code/func/radarr.py @@ -0,0 +1,163 @@ +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" + + # Add the top 5 movies and their years to a list of dictionaries and their respective tmdbIds + movies = [ + {"title": response[i]["title"], "year": response[i]["year"]} + for i in range(min(5, len(response))) + ] + tmdb_ids = {} + for i in range(min(5, len(response))): + tmdb_ids[response[i]["tmdbId"]] = {"description": response[i]["overview"]} + # Try to choose from one of the usual 2 poster images available, + # if not, then just set the "poster" to None + try: + try: + tmdb_ids[response[i]["tmdbId"]]["remotePoster"] = response[i]["images"][0]["remoteUrl"] + except IndexError: + tmdb_ids[response[i]["tmdbId"]]["remotePoster"] = response[i]["images"][1]["remoteUrl"] + except IndexError: + tmdb_ids[response[i]["tmdbId"]]["remotePoster"] = None + + return movies, tmdb_ids + + +""" +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, movies: list, tmdb_ids: dict, *, timeout=180.0): + super().__init__(timeout=timeout) + self.add_item(AddMovieDropdown(movies, tmdb_ids)) + + +class AddMovieDropdown(discord.ui.Select): + def __init__(self, movies: list, tmdb_ids: dict, *, timeout=180.0): + self.movies = movies + self.tmdb_ids = tmdb_ids + super().__init__( + placeholder="Select from the dropdown", + options=[ + discord.SelectOption(label=f"{movie['title']} ({movie['year']})") + for movie in movies + ], + ) + + async def callback(self, interaction: discord.Interaction): + # Convert the options to a list of strings and get the index of the selected option + string_options = [option.label for option in self.options] + index = string_options.index(interaction.data["values"][0]) + # Convert the tmdbIds dictionary to a list and get the tmdbId of the selected movie + tmdb_id_list = list(self.tmdb_ids.keys()) + tmdb_id = tmdb_id_list[index] + tmdbFull = self.tmdb_ids[tmdb_id] + + embed = discord.Embed( + title="Is this the movie you want to add?", + description=f"**{self.movies[index]['title']}**\n\n{tmdbFull['description']}", + color=0xD01B86 + ) + embed.set_image(url=tmdbFull["remotePoster"]) + view = RequestButtonView(tmdb_id) + 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) + # # Add the movie to the Radarr library + # 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 new file mode 100644 index 0000000..bdc5860 --- /dev/null +++ b/code/global_variables.py @@ -0,0 +1,72 @@ +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": ""} + + 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, +} diff --git a/code/validate_config.py b/code/validate_config.py new file mode 100644 index 0000000..191cb03 --- /dev/null +++ b/code/validate_config.py @@ -0,0 +1,164 @@ +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 + ): + config["AVAILABLE_QUALITY_IDS"] = {} + for entry in data: + config["AVAILABLE_QUALITY_IDS"][str(entry["id"])] = entry["name"] + + LOG.error("Empty or invalid QUALITY_PROFILE_ID passed. Pass one of the valid IDs which are now listed within the config.ini file.") + 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 + + # Make sure connection to Jellyfin API can be established + jellyfin_headers = { + "Content-Type": "application/json", + "Authorization": f"MediaBrowser Client=\"other\", device=\"CordArr\", DeviceId=\"cordarr-device-id\", Version=\"0.0.0\", 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": "" + } diff --git a/cordarr.png b/cordarr.png new file mode 100644 index 0000000..0022e37 Binary files /dev/null and b/cordarr.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a63db88 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.8.2 +discord.py==2.3.2 +requests==2.28.2 +humanize==4.9.0 \ No newline at end of file