Overhaul + Sonarr support!

This commit is contained in:
Parker M. 2025-01-19 23:41:53 -06:00
parent 86b12da175
commit b5bd2e36b6
Signed by: parker
GPG Key ID: 505ED36FC12B5D5E
18 changed files with 1078 additions and 695 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
__pycache__
config.ini
config.yaml
cordarr.db
.DS_Store

View File

@ -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
<br>
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
<br>
<br>
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

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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))

View File

@ -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")

View File

@ -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)

View File

@ -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

298
code/utils/config.py Normal file
View File

@ -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

57
code/utils/content_add.py Normal file
View File

@ -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

59
code/utils/content_get.py Normal file
View File

@ -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

203
code/utils/content_view.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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": "",
}

View File

@ -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
wonderwords==2.2.0
# validators
# jsonschema
# pyyaml
# discord py