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.
+
+
+
+
+ CordArr
+
+
+
+ Control your Radarr/Sonarr library and create Jellyfin accounts in Discord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# 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