Merge branch 'dev'
Some checks failed
Create and publish a Docker image / build-and-push-image (push) Has been cancelled

This commit is contained in:
Parker M. 2025-01-22 16:07:47 -06:00
commit 557a646d65
Signed by: parker
GPG Key ID: 505ED36FC12B5D5E
11 changed files with 192 additions and 163 deletions

View File

@ -1,10 +1,9 @@
import discord import discord
from discord.ext import commands from discord.ext import commands, tasks
from discord.ext import tasks
import os import os
from utils.database import Base, engine
import utils.config as config import utils.config as config
from utils.jellyfin_delete import delete_accounts
class MyBot(commands.Bot): class MyBot(commands.Bot):
@ -15,7 +14,7 @@ class MyBot(commands.Bot):
) )
async def setup_hook(self): async def setup_hook(self):
delete_old_temp_accounts.start() delete_accounts_task.start()
for ext in os.listdir("./code/cogs"): for ext in os.listdir("./code/cogs"):
if ext.endswith(".py"): if ext.endswith(".py"):
await self.load_extension(f"cogs.{ext[:-3]}") await self.load_extension(f"cogs.{ext[:-3]}")
@ -30,8 +29,11 @@ async def on_ready():
config.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(minutes=1)
async def delete_old_temp_accounts(): async def delete_accounts_task():
from utils.jellyfin_delete import delete_accounts
Base.metadata.create_all(bind=engine)
delete_accounts() delete_accounts()

View File

@ -1,9 +1,10 @@
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
import sqlite3
from utils.database import Session
from utils.jellyfin_create import create_jellyfin_account from utils.jellyfin_create import create_jellyfin_account
from utils.models import JellyfinAccounts
from utils.config import ( from utils.config import (
JELLYFIN_PUBLIC_URL, JELLYFIN_PUBLIC_URL,
JELLYFIN_ENABLED, JELLYFIN_ENABLED,
@ -19,15 +20,15 @@ class NewAccount(commands.Cog):
@app_commands.check(lambda inter: JELLYFIN_ENABLED) @app_commands.check(lambda inter: JELLYFIN_ENABLED)
async def newaccount(self, interaction: discord.Interaction) -> None: async def newaccount(self, interaction: discord.Interaction) -> None:
"""Create a new temporary Jellyfin account""" """Create a new temporary Jellyfin account"""
# Defer in case it takes too long
await interaction.response.defer(ephemeral=True)
# Make sure the user doesn't already have an account # Make sure the user doesn't already have an account
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() account = (
cursor.execute( session.query(JellyfinAccounts)
"SELECT * FROM jellyfin_accounts WHERE user_id = ?", .filter(JellyfinAccounts.user_id == interaction.user.id)
(interaction.user.id,), .first()
) )
account = cursor.fetchone()
db.close()
# Account already allocated # Account already allocated
if account: if account:
embed = discord.Embed( embed = discord.Embed(
@ -39,9 +40,7 @@ class NewAccount(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
return await interaction.response.send_message( return await interaction.followup.send(embed=embed)
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)
@ -54,17 +53,15 @@ class NewAccount(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
await interaction.response.send_message( await interaction.followup.send(embed=embed)
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=( description=(
# fmt: off # fmt: off
"Here is your temporary account information.\n\n", "Here is your temporary account information.\n\n"
f"**Server URL:** `[{JELLYFIN_PUBLIC_URL}]({JELLYFIN_PUBLIC_URL})`\n" f"**Server URL:** `{JELLYFIN_PUBLIC_URL}`\n"
f"**Username:** `{response[0]}`\n" f"**Username:** `{response[0]}`\n"
f"**Password:** `{response[1]}`\n\n" f"**Password:** `{response[1]}`\n\n"
"Your account will be automatically deleted in" "Your account will be automatically deleted in"
@ -84,9 +81,7 @@ class NewAccount(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
return await interaction.response.send_message( return await interaction.followup.send(embed=embed)
embed=embed, ephemeral=True
)
async def setup(bot): async def setup(bot):

View File

@ -31,6 +31,8 @@ class Request(commands.Cog):
name: str, name: str,
) -> None: ) -> None:
"""Request a movie or tv show to be added to the library""" """Request a movie or tv show to be added to the library"""
# Could take a sec. so defer the response
await interaction.response.defer(ephemeral=True)
# Get matching content from relevant service # Get matching content from relevant service
if form == "Movie": if form == "Movie":
content_data = get_content( content_data = get_content(
@ -54,9 +56,7 @@ class Request(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
return await interaction.response.send_message( return await interaction.followup.send(embed=embed, ephemeral=True)
embed=embed, ephemeral=True
)
if content_data == "ALREADY ADDED": if content_data == "ALREADY ADDED":
embed = discord.Embed( embed = discord.Embed(
@ -70,9 +70,7 @@ class Request(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
return await interaction.response.send_message( return await interaction.followup.send(embed=embed, ephemeral=True)
embed=embed, ephemeral=True
)
embed = discord.Embed( embed = discord.Embed(
title="Results Found", title="Results Found",
@ -103,9 +101,7 @@ class Request(commands.Cog):
SONARR_QUALITY_PROFILE_ID, SONARR_QUALITY_PROFILE_ID,
) )
await interaction.response.send_message( await interaction.followup.send(embed=embed, view=view, ephemeral=True)
embed=embed, view=view, ephemeral=True
)
async def setup(bot): async def setup(bot):

View File

@ -2,8 +2,9 @@ import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
import requests import requests
import sqlite3
from utils.models import Requests
from utils.database import Session
from utils.config import ( from utils.config import (
RADARR_HOST_URL, RADARR_HOST_URL,
RADARR_HEADERS, RADARR_HEADERS,
@ -20,17 +21,20 @@ class Status(commands.Cog):
async def status(self, interaction: discord.Interaction) -> None: 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"""
# Defer the response # Defer the response
await interaction.response.defer() await interaction.response.defer(ephemeral=True)
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() requested_content = (
cursor.execute( session.query(
"SELECT title, release_year, local_id, tmdbid, tvdbid FROM" Requests.title,
" requests WHERE user_id = ?", Requests.release_year,
(interaction.user.id,), Requests.local_id,
) Requests.tmdbid,
requested_content = cursor.fetchall() Requests.tvdbid,
db.close() )
.filter(Requests.user_id == interaction.user.id)
.all()
)
# No content requested # No content requested
if len(requested_content) == 0: if len(requested_content) == 0:
@ -42,9 +46,7 @@ class Status(commands.Cog):
), ),
color=0xD01B86, color=0xD01B86,
) )
return await interaction.response.send_message( return await interaction.followup.send(embed=embed)
embed=embed, ephemeral=True
)
# Create template embed # Create template embed
embed = discord.Embed( embed = discord.Embed(
@ -76,8 +78,8 @@ class Status(commands.Cog):
embed.description += radarr_desc + sonarr_desc + non_queue_desc embed.description += radarr_desc + sonarr_desc + non_queue_desc
# Send the embed # Send the follow-up message
await interaction.response.send_message(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed)
def unpack_content(self, requested_content: list) -> tuple: def unpack_content(self, requested_content: list) -> tuple:
""" """
@ -94,18 +96,18 @@ class Status(commands.Cog):
sonarr_content_info = {} sonarr_content_info = {}
for content in requested_content: for content in requested_content:
title, (release_year), local_id, tmdbid, tvdbid = content title, release_year, local_id, tmdbid, tvdbid = content
if tmdbid is not None: if tmdbid is not None:
radarr_content_info[int(local_id)] = { radarr_content_info[local_id] = {
"title": title, "title": title,
"release_year": int(release_year), "release_year": release_year,
"tmdbid": int(tmdbid), "tmdbid": tmdbid,
} }
else: else:
sonarr_content_info[int(local_id)] = { sonarr_content_info[local_id] = {
"title": title, "title": title,
"release_year": int(release_year), "release_year": release_year,
"tvdbid": int(tvdbid), "tvdbid": tvdbid,
} }
return radarr_content_info, sonarr_content_info return radarr_content_info, sonarr_content_info
@ -171,7 +173,7 @@ class Status(commands.Cog):
for content in requested_content: for content in requested_content:
title, release_year, local_id, tmdbid, _ = content title, release_year, local_id, tmdbid, _ = content
# If not in queue # If not in queue
if int(local_id) not in added_ids: if local_id not in added_ids:
# Pull the movie data from the service # Pull the movie data from the service
if tmdbid is not None: if tmdbid is not None:
data = requests.get( data = requests.get(
@ -187,23 +189,26 @@ class Status(commands.Cog):
# If the movie has a file, then it has finished downloading # If the movie has a file, then it has finished downloading
if data.get("hasFile", True): if data.get("hasFile", True):
# Remove from database # Remove from database
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() request = (
cursor.execute( session.query(Requests)
"DELETE FROM requests WHERE user_id = ? AND" .filter(Requests.user_id == user_id)
" local_id = ?", .filter(Requests.local_id == local_id)
(user_id, int(local_id)), .first()
) )
db.commit() session.delete(request)
db.close() session.commit()
# If series and only a portion of episodes have been downloaded # If series and only a portion of episodes have been downloaded
if data.get("statistics").get("percentOfEpisodes"): # If data["statistics"] exists and is not None
description += ( if "statistics" in data and data["statistics"] != None:
f"\n**{title} ({release_year})** - Status: `NOT" if "percentOfEpisodes" in data["statistics"]:
" FOUND" description += (
f" ({int(data['statistics']['percentOfEpisodes'])}%" f"\n**{title} ({release_year})** - Status: `NOT"
" of eps.)`" " FOUND"
) f" ({int(data['statistics']['percentOfEpisodes'])}%"
" of eps.)`"
)
# All other scenarios, download not found # All other scenarios, download not found
else: else:
description += ( description += (

View File

@ -4,7 +4,6 @@ import sys
import os import os
import logging import logging
import requests import requests
import sqlite3
from colorlog import ColoredFormatter from colorlog import ColoredFormatter
@ -108,10 +107,9 @@ schema = {
def load_config() -> None: def load_config() -> None:
""" """
Load DB, then load and validate the config file Load the config file and validate it
If the file does not exist, generate it If the file does not exist, generate it
""" """
database_setup()
if os.path.exists("/.dockerenv"): if os.path.exists("/.dockerenv"):
file_path = "config/config.yaml" file_path = "config/config.yaml"
else: else:
@ -157,26 +155,6 @@ jellyfin:
) )
def database_setup() -> None:
"""
Create the database if it does not exist
"""
if not os.path.exists("data"):
os.makedirs("data")
db = sqlite3.connect("data/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: def validate_config(contents) -> None:
""" """
Validate the contents of the config file and assign variables Validate the contents of the config file and assign variables

View File

@ -1,6 +1,7 @@
import discord import discord
import sqlite3
from utils.models import Requests
from utils.database import Session
from utils.content_add import add_content from utils.content_add import add_content
""" """
@ -147,7 +148,8 @@ class RequestButtonView(discord.ui.View):
), ),
color=0xD01B86, color=0xD01B86,
) )
await interaction.response.send_message(embed=embed) await interaction.response.edit_message(view=None)
await interaction.followup.send(embed=embed)
# Alert the user that the content failed to be added # Alert the user that the content failed to be added
else: else:
embed = discord.Embed( embed = discord.Embed(
@ -158,33 +160,30 @@ class RequestButtonView(discord.ui.View):
f" {self.service} library." f" {self.service} library."
), ),
) )
return await interaction.response.send_message(embed=embed) await interaction.delete_original_response()
return await interaction.response.edit_message(embed=embed)
# Keep track of the requests for the `/status` command # Keep track of the requests for the `/status` command
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() session.add(
cursor.execute( Requests(
"INSERT INTO requests (title, release_year, local_id, tmdbid," title=self.content_info["title"],
" tvdbid, user_id) VALUES (?, ?, ?, ?, ?, ?)", release_year=self.content_info["year"],
( local_id=local_id,
self.content_info["title"], tmdbid=(
self.content_info["year"], self.content_info["contentId"]
local_id, if self.service == "radarr"
( else None
self.content_info["contentId"] ),
if self.service == "radarr" tvdbid=(
else None None
), if self.service == "radarr"
( else self.content_info["contentId"]
None ),
if self.service == "radarr" user_id=interaction.user.id,
else self.content_info["contentId"] )
), )
interaction.user.id, session.commit()
),
)
db.commit()
db.close()
@discord.ui.button(label="Don't Request", style=discord.ButtonStyle.danger) @discord.ui.button(label="Don't Request", style=discord.ButtonStyle.danger)
async def dont_request_button( async def dont_request_button(
@ -200,4 +199,4 @@ class RequestButtonView(discord.ui.View):
), ),
color=0xD01B86, color=0xD01B86,
) )
await interaction.response.send_message(embed=embed) await interaction.response.edit_message(embed=embed, view=None)

13
code/utils/database.py Normal file
View File

@ -0,0 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
if not os.path.exists("data"):
os.makedirs("data")
database_url = "sqlite:///data/cordarr.db"
engine = create_engine(database_url)
Session = sessionmaker(bind=engine)
Base = declarative_base()

View File

@ -1,10 +1,11 @@
import datetime import datetime
import requests import requests
import random import random
import sqlite3
from wonderwords import RandomWord from wonderwords import RandomWord
from string import ascii_lowercase, digits from string import ascii_lowercase, digits
from utils.database import Session
from utils.models import JellyfinAccounts
from utils.config import ( from utils.config import (
JELLYFIN_URL, JELLYFIN_URL,
JELLYFIN_HEADERS, JELLYFIN_HEADERS,
@ -67,14 +68,14 @@ def create_jellyfin_account(user_id):
return False return False
# Add the information to the database # Add the information to the database
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() session.add(
cursor.execute( JellyfinAccounts(
"INSERT INTO jellyfin_accounts (user_id, jellyfin_user_id," user_id=user_id,
" deletion_time) VALUES (?, ?, ?)", jellyfin_user_id=jellyfin_user_id,
(user_id, jellyfin_user_id, deletion_time), deletion_time=deletion_time,
) )
db.commit() )
db.close() session.commit()
return username, password return username, password

View File

@ -1,8 +1,9 @@
import datetime import datetime
import sqlite3
import requests import requests
from utils.config import JELLYFIN_URL, JELLYFIN_HEADERS from utils.database import Session
from utils.models import JellyfinAccounts
from utils.config import LOG, JELLYFIN_URL, JELLYFIN_HEADERS
def delete_accounts(): def delete_accounts():
@ -10,29 +11,36 @@ def delete_accounts():
Delete Jellyfin accounts that have passed their deletion time Delete Jellyfin accounts that have passed their deletion time
""" """
# Get all expired Jellyfin accounts # Get all expired Jellyfin accounts
db = sqlite3.connect("data/cordarr.db") with Session() as session:
cursor = db.cursor() jellyfin_user_ids = (
cursor.execute( session.query(JellyfinAccounts.jellyfin_user_id)
"SELECT jellyfin_user_id FROM jellyfin_accounts WHERE" .filter(JellyfinAccounts.deletion_time < datetime.datetime.now())
" deletion_time < ?", .all()
(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() # Delete each account
db.close() for jellyfin_user_id in jellyfin_user_ids:
print(f"Deleting account {jellyfin_user_id[0]}")
try:
response = requests.delete(
f"{JELLYFIN_URL}/Users/{jellyfin_user_id[0]}",
headers=JELLYFIN_HEADERS,
)
response.raise_for_status()
# Get the account and delete it
account = (
session.query(JellyfinAccounts)
.filter(
JellyfinAccounts.jellyfin_user_id
== jellyfin_user_id[0]
)
.first()
)
session.delete(account)
except:
LOG.error(
"Failed deleting Jellyfin account w/ ID"
f" {jellyfin_user_id[0]}"
)
# Commit changes
session.commit()

31
code/utils/models.py Normal file
View File

@ -0,0 +1,31 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
BigInteger,
)
from utils.database import Base
class Requests(Base):
__tablename__ = "requests"
id = Column(Integer, primary_key=True, index=True)
title = Column(String)
release_year = Column(Integer)
local_id = Column(Integer)
tmdbid = Column(Integer)
tvdbid = Column(Integer)
user_id = Column(BigInteger)
class JellyfinAccounts(Base):
__tablename__ = "jellyfin_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger)
jellyfin_user_id = Column(String)
deletion_time = Column(DateTime)

View File

@ -4,4 +4,5 @@ wonderwords==2.2.0
PyYAML==6.0.2 PyYAML==6.0.2
jsonschema==4.23.0 jsonschema==4.23.0
jsonschema-specifications==2024.10.1 jsonschema-specifications==2024.10.1
discord.py==2.4.0 discord.py==2.4.0
SQLAlchemy==2.0.37