Compare commits

...

3 Commits

Author SHA1 Message Date
82786829ca
Add Docker image
Some checks are pending
Create and publish a Docker image / build-and-push-image (push) Waiting to run
2025-01-19 23:54:05 -06:00
dc86e68637
Add Docker image 2025-01-19 23:53:26 -06:00
b5bd2e36b6
Overhaul + Sonarr support! 2025-01-19 23:41:53 -06:00
22 changed files with 1179 additions and 698 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
__pycache__
config.yaml
cordarr.db
.DS_Store
notes.txt

59
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Create and publish a Docker image
# Run workflow on push events to the main branch.
on:
push:
branches: ['main']
# Define the package registry and image name as environment variables.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Create the single job that builds and publishes the Docker image.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: false

5
.gitignore vendored
View File

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

View File

@ -25,37 +25,39 @@
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*
# Self-hosting
# Instructions
## Docker
To run Guava in Docker, use the provided [docker-compose.yaml](docker-compose.yaml) file as a template for the container. Use the configuration section below to fill out the necessary information.
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`
## Bare metal
To run Guava on bare metal, follow the steps below.
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.
1. Install Python 3 and Pip
2. Clone this repository
3. Install the requirements with `pip install -r requirements.txt`
4. Run the `code/bot.py` file
5. Input information into the newly created config.yaml file.
6. Re-run the `code/bot.py` 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

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

@ -0,0 +1,304 @@
import jsonschema
import validators
import yaml
import sys
import os
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()
if os.path.exists("/.dockerenv"):
file_path = "/config/config.yaml"
else:
file_path = "config.yaml"
try:
with open(file_path, "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": "",
}

7
docker-compose.yaml Normal file
View File

@ -0,0 +1,7 @@
services:
cordarr:
container_name: cordarr
image: ghcr.io/packetparker/cordarr:latest
volumes:
- /path/on/system:/config
- /path/on/system:/data

12
dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.source="https://git.pkrm.dev/parker/cordarr"
LABEL org.opencontainers.image.authors="parker <mailto:contact@pkrm.dev>"
WORKDIR /
COPY . .
RUN pip install -r requirements.txt
ENTRYPOINT [ "python" ]
CMD [ "-u", "code/bot.py" ]

View File

@ -1,5 +1,8 @@
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
PyYAML==6.0.2
validators==0.34.0
jsonschema==4.23.0
jsonschema-specifications==2024.10.1
discord.py==2.4.0