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
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
config.ini
|
config.yaml
|
||||||
cordarr.db
|
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.
|
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.
|
1. Install Python 3 and Pip
|
||||||
|
2. Clone this repository
|
||||||
Fill out the configuration options, then re-run the bot, and everything *should* just work. For information on each configuration option, look below.
|
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
|
Field | Description
|
||||||
--- | ---
|
--- | ---
|
||||||
BOT_TOKEN | The token for your bot. Create a bot at [discord.com/developers](https://discord.com/developers)
|
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
|
Field | Description
|
||||||
--- | ---
|
--- | ---
|
||||||
JELLYFIN_URL | URL for your Jellyfin server (e.g. http://localhost:8096)
|
HOST_URL | URL for your Radarr/Sonarr instance (e.g. http://localhost:7878)
|
||||||
JELLYFIN_API_KEY | API key for Jellyfin - can be created in `Dashboard > API Keys`
|
API_KEY | API key for Radarr/Sonarr, found in `Settings > General > API Key`
|
||||||
ACCOUNT_TIME | Amount of time, in hours, that temporary Jellyfin accounts should exist before being deleted
|
ROOT_FOLDER_PATH | Folder path found at the bottom of the page in `Settings > Media Management`
|
||||||
SIMPLE_ACCOUNTS | `true/false` : Whether or not to have simple dictionary word passwords for temporary accounts
|
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>
|
## JELLYFIN | OPTIONAL
|
||||||
<br>
|
Field | Description
|
||||||
|
--- | ---
|
||||||
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.
|
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
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord.ext import tasks
|
from discord.ext import tasks
|
||||||
import datetime
|
|
||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from validate_config import create_config
|
import utils.config as config
|
||||||
from func.jellyfin import delete_jellyfin_account
|
from utils.jellyfin_delete import delete_accounts
|
||||||
from global_variables import LOG, BOT_TOKEN
|
|
||||||
|
|
||||||
|
|
||||||
class MyBot(commands.Bot):
|
class MyBot(commands.Bot):
|
||||||
@ -18,7 +15,6 @@ class MyBot(commands.Bot):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
create_config()
|
|
||||||
delete_old_temp_accounts.start()
|
delete_old_temp_accounts.start()
|
||||||
for ext in os.listdir("./code/cogs"):
|
for ext in os.listdir("./code/cogs"):
|
||||||
if ext.endswith(".py"):
|
if ext.endswith(".py"):
|
||||||
@ -31,21 +27,14 @@ bot.remove_command("help")
|
|||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
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)
|
@tasks.loop(seconds=60)
|
||||||
async def delete_old_temp_accounts():
|
async def delete_old_temp_accounts():
|
||||||
# Get all jellyfin user IDs that have passed their deletion time
|
delete_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:
|
|
||||||
delete_jellyfin_account(jellyfin_user_id[0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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(
|
embed = discord.Embed(
|
||||||
title="Jellyfin Account Creation Disabled",
|
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.",
|
description=(
|
||||||
color=0xD01B86
|
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:
|
else:
|
||||||
raise error
|
raise error
|
||||||
|
|
||||||
|
@ -3,8 +3,12 @@ from discord import app_commands
|
|||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from func.jellyfin import create_jellyfin_account
|
from utils.jellyfin_create import create_jellyfin_account
|
||||||
from global_variables import JELLYFIN_URL, ENABLE_JELLYFIN_TEMP_ACCOUNTS, ACCOUNT_TIME
|
from utils.config import (
|
||||||
|
JELLYFIN_URL,
|
||||||
|
JELLYFIN_ENABLED,
|
||||||
|
ACCOUNT_TIME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NewAccount(commands.Cog):
|
class NewAccount(commands.Cog):
|
||||||
@ -12,47 +16,77 @@ class NewAccount(commands.Cog):
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@app_commands.command()
|
@app_commands.command()
|
||||||
@app_commands.check(lambda inter: ENABLE_JELLYFIN_TEMP_ACCOUNTS)
|
@app_commands.check(lambda inter: JELLYFIN_ENABLED)
|
||||||
async def newaccount(self, interaction: discord.Interaction):
|
async def newaccount(self, interaction: discord.Interaction) -> None:
|
||||||
"Create a new temporary Jellyfin account"
|
"""Create a new temporary Jellyfin account"""
|
||||||
# Make sure the user doesn't already have an account
|
# Make sure the user doesn't already have an account
|
||||||
db = sqlite3.connect("cordarr.db")
|
db = sqlite3.connect("cordarr.db")
|
||||||
cursor = db.cursor()
|
cursor = db.cursor()
|
||||||
cursor.execute(
|
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(
|
embed = discord.Embed(
|
||||||
title="Account Already Exists",
|
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.",
|
description=(
|
||||||
color=0xD01B86
|
"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
|
# Create a new Jellyfin account for the user
|
||||||
response = create_jellyfin_account(interaction.user.id)
|
response = create_jellyfin_account(interaction.user.id)
|
||||||
if response:
|
if response:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="Account Created",
|
title="Account Created",
|
||||||
description="Your account has been successfully created. Check your DMs for your account information.",
|
description=(
|
||||||
color=0xD01B86
|
"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
|
# Send the user their account information
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="Jellyfin Account Information",
|
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.",
|
description=(
|
||||||
color=0xD01B86
|
# 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)
|
await interaction.user.send(embed=embed)
|
||||||
|
# If account not created for some reason
|
||||||
else:
|
else:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="Unknown Error Occured",
|
title="Unknown Error Occured",
|
||||||
description="Error creating Jellyfin account. Please try again. If the error persists, contact an administrator.",
|
description=(
|
||||||
color=0xD01B86
|
"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):
|
async def setup(bot):
|
||||||
|
@ -1,53 +1,111 @@
|
|||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import 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):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@app_commands.command(name="movie")
|
@app_commands.command()
|
||||||
@app_commands.describe(name="Name of the movie to add")
|
@app_commands.describe(form="Are you requesting a Movie or Show?")
|
||||||
async def request_movie(self, interaction: discord.Interaction, name: str):
|
@app_commands.describe(name="Name of the content")
|
||||||
"Request a movie to be added to the Radarr library"
|
async def request(
|
||||||
movie_data = get_movies(name)
|
self,
|
||||||
if movie_data == "NO RESULTS":
|
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(
|
embed = discord.Embed(
|
||||||
title="No Results",
|
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.",
|
description=(
|
||||||
color=0xD01B86
|
# 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(
|
embed = discord.Embed(
|
||||||
title="Already Added",
|
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.",
|
description=(
|
||||||
color=0xD01B86
|
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(
|
embed = discord.Embed(
|
||||||
title="Results Found",
|
title="Results Found",
|
||||||
description="Please select the movie you would like to add from the dropdown below.",
|
description=(
|
||||||
color=0xD01B86
|
f"Please select from the top {len(content_data)} results from"
|
||||||
|
f" {'radarr' if form == 'Movie' else 'sonarr'} in the"
|
||||||
|
" dropdown below."
|
||||||
|
),
|
||||||
|
color=0xD01B86,
|
||||||
|
)
|
||||||
|
# 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,
|
||||||
)
|
)
|
||||||
view = AddMovieView(movie_data)
|
|
||||||
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
|
||||||
|
|
||||||
@app_commands.command(name="show")
|
await interaction.response.send_message(
|
||||||
@app_commands.describe(name="Name of the show/series to add")
|
embed=embed, view=view, ephemeral=True
|
||||||
async def request_show(self, interaction: discord.Interaction, name: str):
|
|
||||||
"Request a show/series to be added to the Sonarr library"
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Coming Soon",
|
|
||||||
description="This feature is not yet implemented. Check back later.",
|
|
||||||
color=0xD01B86
|
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
|
@ -3,10 +3,13 @@ from discord import app_commands
|
|||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
import requests
|
import requests
|
||||||
import sqlite3
|
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):
|
class Status(commands.Cog):
|
||||||
@ -14,97 +17,229 @@ class Status(commands.Cog):
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@app_commands.command()
|
@app_commands.command()
|
||||||
async def status(self, interaction: discord.Interaction):
|
async def status(self, interaction: discord.Interaction) -> None:
|
||||||
"Get the status of the movies you have requested"
|
"""Get the status of the movies you have requested"""
|
||||||
# Get all the movie_ids that were requested by the user
|
|
||||||
db = sqlite3.connect("cordarr.db")
|
db = sqlite3.connect("cordarr.db")
|
||||||
cursor = db.cursor()
|
cursor = db.cursor()
|
||||||
cursor.execute(
|
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,),
|
(interaction.user.id,),
|
||||||
)
|
)
|
||||||
requested_movies = cursor.fetchall()
|
requested_content = cursor.fetchall()
|
||||||
|
db.close()
|
||||||
|
|
||||||
users_movies = {} # Dictionary to store the movies that the user has requested
|
# No content requested
|
||||||
for movie_id, movie_title in requested_movies:
|
if len(requested_content) == 0:
|
||||||
users_movies[movie_id] = movie_title
|
|
||||||
# If theres no movies, return a message saying so
|
|
||||||
if not users_movies:
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="No Movies Requested",
|
title="No Content 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.",
|
description=(
|
||||||
color=0xD01B86
|
"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)
|
return await interaction.response.send_message(
|
||||||
# Otherwise, create the default embed to display the movies being downloaded
|
embed=embed, ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create template embed
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="Movies Requested",
|
title="Requested Content",
|
||||||
description="Here are the movies you have requested that are currently being downloaded:\n",
|
description=(
|
||||||
color=0xD01B86
|
"Below are the movies/shows you have requested that are"
|
||||||
|
" currently being downloaded:\n"
|
||||||
|
),
|
||||||
|
color=0xD01B86,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Now, we get the download status of all movies from the Radarr queue
|
# Unpack the content
|
||||||
response = requests.get(
|
radarr_content_info, sonarr_content_info = self.unpack_content(
|
||||||
f"{RADARR_HOST_URL}/api/v3/queue/", headers=RADARR_HEADERS
|
requested_content
|
||||||
).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:
|
# Get the descriptions and local IDs found in queue
|
||||||
# Sometimes movies will download extremely show and therefore might
|
radarr_desc, radarr_added_ids = self.process_queue(
|
||||||
# show 'days' in the time left, so strptime appropriately
|
radarr_content_info, "radarr"
|
||||||
time_left = humanize.precisedelta(
|
)
|
||||||
datetime.datetime.strptime(movie["timeleft"], "%d.%H:%M:%S")
|
sonarr_desc, sonarr_added_ids = self.process_queue(
|
||||||
- datetime.datetime.strptime("00:00:00", "%H:%M:%S"),
|
sonarr_content_info, "sonarr"
|
||||||
minimum_unit="seconds",
|
|
||||||
)
|
)
|
||||||
except KeyError or ValueError:
|
|
||||||
time_left = "Unknown"
|
|
||||||
|
|
||||||
# Add all the information
|
added_ids = radarr_added_ids + sonarr_added_ids
|
||||||
embed.description += f"\n{count}. **{users_movies[movie_id]}** - Time Left: ` {time_left} `"
|
# 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:
|
else:
|
||||||
embed.description += f"\n{count}. **{users_movies[movie_id]}** - Status: `{str(movie['status']).upper()}`"
|
sonarr_content_info[local_id] = {
|
||||||
|
"title": title,
|
||||||
|
"release_year": release_year,
|
||||||
|
"tvdbid": tvdbid,
|
||||||
|
}
|
||||||
|
|
||||||
# If a movie wasn't found in the Radarr queue, then it has either finished downloading
|
return radarr_content_info, sonarr_content_info
|
||||||
# or the movie was never found for download
|
|
||||||
if len(added_movie_ids) != len(users_movies.keys()):
|
def process_queue(self, content_info: dict, service: str) -> str:
|
||||||
# Grab all of the "missing" movies to see if a movie is missing or finished downloading
|
"""
|
||||||
response = requests.get(
|
Given a dictionary of requested content and "sonarr"/"radarr", process the queue
|
||||||
f"{RADARR_HOST_URL}/api/v3/wanted/missing", headers=RADARR_HEADERS
|
|
||||||
|
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()
|
).json()
|
||||||
for movie in response["records"]:
|
|
||||||
movie_id = movie["id"]
|
for download in queue["records"]:
|
||||||
if movie_id in users_movies.keys() and movie_id not in added_movie_ids:
|
id_str = "movieId" if service == "radarr" else "seriesId"
|
||||||
count += 1
|
# If the content was requested by the user
|
||||||
added_movie_ids.append(movie_id)
|
if (
|
||||||
embed.description += f"\n{count}. **{users_movies[movie_id]}** - Status: ` NOT FOUND `"
|
download[id_str] in content_info.keys()
|
||||||
# If there are still movies that haven't been added to the embed, then they
|
and download[id_str] not in added_ids
|
||||||
# have finished downloading and can be removed from the database
|
):
|
||||||
for movie_id in users_movies.keys():
|
# Append local ID
|
||||||
if movie_id not in added_movie_ids:
|
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(
|
cursor.execute(
|
||||||
"DELETE FROM movies WHERE user_id = ? AND movie_id = ?",
|
"DELETE FROM requests WHERE user_id = ? AND"
|
||||||
(interaction.user.id, movie_id),
|
" local_id = ?",
|
||||||
|
(user_id, local_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
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`"
|
||||||
|
)
|
||||||
|
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
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):
|
async def setup(bot):
|
||||||
|
@ -9,7 +9,9 @@ class TreeSync(commands.Cog):
|
|||||||
@commands.command()
|
@commands.command()
|
||||||
@commands.dm_only()
|
@commands.dm_only()
|
||||||
@commands.is_owner()
|
@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:
|
if not guild or guild == None:
|
||||||
await self.bot.tree.sync()
|
await self.bot.tree.sync()
|
||||||
await ctx.author.send("Synced commands globally")
|
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 datetime
|
||||||
import requests
|
import requests
|
||||||
import random
|
import random
|
||||||
import string
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from wonderwords import RandomWord
|
from wonderwords import RandomWord
|
||||||
|
from string import ascii_lowercase, digits
|
||||||
|
|
||||||
from global_variables import JELLYFIN_URL, JELLYFIN_HEADERS, ACCOUNT_TIME, SIMPLE_PASSWORDS
|
from utils.config import (
|
||||||
|
JELLYFIN_URL,
|
||||||
"""
|
JELLYFIN_HEADERS,
|
||||||
Create a new Jellyfin account for the user and return the username and password
|
ACCOUNT_TIME,
|
||||||
"""
|
SIMPLE_PASSWORDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_jellyfin_account(user_id):
|
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)
|
username = RandomWord().word(word_min_length=5, word_max_length=5)
|
||||||
if SIMPLE_PASSWORDS:
|
if SIMPLE_PASSWORDS:
|
||||||
password = RandomWord().word(word_min_length=5, word_max_length=10)
|
password = RandomWord().word(word_min_length=5, word_max_length=10)
|
||||||
else:
|
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
|
# Create the new Jellyfin account
|
||||||
request_1 = requests.post(
|
request_1 = requests.post(
|
||||||
f"{JELLYFIN_URL}/Users/New",
|
f"{JELLYFIN_URL}/Users/New",
|
||||||
headers=JELLYFIN_HEADERS,
|
headers=JELLYFIN_HEADERS,
|
||||||
json={"Name": username, "Password": password},
|
json={"Name": username, "Password": password},
|
||||||
)
|
)
|
||||||
|
|
||||||
if request_1.status_code != 200:
|
if request_1.status_code != 200:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -56,36 +70,11 @@ def create_jellyfin_account(user_id):
|
|||||||
db = sqlite3.connect("cordarr.db")
|
db = sqlite3.connect("cordarr.db")
|
||||||
cursor = db.cursor()
|
cursor = db.cursor()
|
||||||
cursor.execute(
|
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),
|
(user_id, jellyfin_user_id, deletion_time),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
return username, password
|
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
|
colorlog==6.8.2
|
||||||
discord.py==2.3.2
|
|
||||||
requests==2.32.0
|
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