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