Compare commits
3 Commits
86b12da175
...
82786829ca
Author | SHA1 | Date | |
---|---|---|---|
82786829ca | |||
dc86e68637 | |||
b5bd2e36b6 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__
|
||||
config.yaml
|
||||
cordarr.db
|
||||
.DS_Store
|
||||
notes.txt
|
59
.github/workflows/docker-publish.yml
vendored
Normal file
59
.github/workflows/docker-publish.yml
vendored
Normal 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
5
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
__pycache__
|
||||
config.ini
|
||||
config.yaml
|
||||
cordarr.db
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
notes.txt
|
48
README.md
48
README.md
@ -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
|
23
code/bot.py
23
code/bot.py
@ -1,13 +1,10 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext import tasks
|
||||
import datetime
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
from validate_config import create_config
|
||||
from func.jellyfin import delete_jellyfin_account
|
||||
from global_variables import LOG, BOT_TOKEN
|
||||
import utils.config as config
|
||||
from utils.jellyfin_delete import delete_accounts
|
||||
|
||||
|
||||
class MyBot(commands.Bot):
|
||||
@ -18,7 +15,6 @@ class MyBot(commands.Bot):
|
||||
)
|
||||
|
||||
async def setup_hook(self):
|
||||
create_config()
|
||||
delete_old_temp_accounts.start()
|
||||
for ext in os.listdir("./code/cogs"):
|
||||
if ext.endswith(".py"):
|
||||
@ -31,21 +27,14 @@ bot.remove_command("help")
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
LOG.info(f"{bot.user} has connected to Discord.")
|
||||
config.LOG.info(f"{bot.user} has connected to Discord.")
|
||||
|
||||
|
||||
@tasks.loop(seconds=60)
|
||||
async def delete_old_temp_accounts():
|
||||
# Get all jellyfin user IDs that have passed their deletion time
|
||||
db = sqlite3.connect("cordarr.db")
|
||||
cursor = db.cursor()
|
||||
cursor.execute("SELECT jellyfin_user_id FROM jellyfin_accounts WHERE deletion_time < ?", (datetime.datetime.now(),))
|
||||
jellyfin_user_ids = cursor.fetchall()
|
||||
|
||||
# Delete the Jellyfin accounts
|
||||
for jellyfin_user_id in jellyfin_user_ids:
|
||||
delete_jellyfin_account(jellyfin_user_id[0])
|
||||
delete_accounts()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bot.run(BOT_TOKEN)
|
||||
config.load_config()
|
||||
bot.run(config.BOT_TOKEN)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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))
|
||||
|
@ -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")
|
||||
|
@ -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)
|
@ -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
304
code/utils/config.py
Normal 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
57
code/utils/content_add.py
Normal 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
59
code/utils/content_get.py
Normal 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
203
code/utils/content_view.py
Normal 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)
|
@ -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
|
38
code/utils/jellyfin_delete.py
Normal file
38
code/utils/jellyfin_delete.py
Normal 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()
|
@ -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
7
docker-compose.yaml
Normal 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
12
dockerfile
Normal 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" ]
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user