diff --git a/Dockerfile b/Dockerfile index 71e2d42a..405934b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,11 @@ -# FROM ghcr.io/linuxserver/baseimage-alpine:3.20 -FROM python:3.11.9-alpine3.20 +# BUILD COMMAND: +# docker build . --no-cache -t conreq +# mkdir config +# RUN COMMAND (Windows): +# docker run -p '7575:7575/tcp' -v $PWD/config:/config conreq +# RUN COMMAND (Linux/Mac): +# docker run -p '7575:7575/tcp' -v $(pwd)/config:/config conreq +FROM python:3.12.12-alpine3.21 ENV DATA_DIR=/config DEBUG=False @@ -41,7 +47,9 @@ RUN \ && \ echo "**** Install Python dependencies ****" \ && \ - pip3 install --no-cache-dir -U -r /app/conreq/requirements/main.txt \ + pip3 install uv \ + && \ + uv pip install --system --no-cache-dir -U -r /app/conreq/requirements/main.txt \ && \ echo "**** Cleanup ****" \ && \ diff --git a/conreq/asgi.py b/conreq/asgi.py index 208a2cf2..0e71e18f 100644 --- a/conreq/asgi.py +++ b/conreq/asgi.py @@ -2,10 +2,8 @@ ASGI config for Conreq project. It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ """ + # pylint: disable=wrong-import-position from django.core.asgi import get_asgi_application from django.urls import path diff --git a/conreq/core/api/__init__.py b/conreq/core/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/conreq/core/api/admin.py b/conreq/core/api/admin.py deleted file mode 100644 index d5cd1133..00000000 --- a/conreq/core/api/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from rest_framework.authtoken.admin import TokenAdmin - -# Register your models here. - -TokenAdmin.raw_id_fields = ["user"] diff --git a/conreq/core/api/apps.py b/conreq/core/api/apps.py deleted file mode 100644 index 59903295..00000000 --- a/conreq/core/api/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "conreq.core.api" diff --git a/conreq/core/api/migrations/__init__.py b/conreq/core/api/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/conreq/core/api/models.py b/conreq/core/api/models.py deleted file mode 100644 index f98221ca..00000000 --- a/conreq/core/api/models.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db.models.signals import post_save -from django.dispatch import receiver -from rest_framework.authtoken.models import Token - -User = get_user_model() - -# Create your models here. -@receiver(post_save, sender=User) -def create_auth_token( - sender, instance=None, created=False, **kwargs -): # pylint: disable=unused-argument - # Create the token if it doesn't exist - if not hasattr(instance, "auth_token"): - Token.objects.create(user=instance) diff --git a/conreq/core/api/permissions.py b/conreq/core/api/permissions.py deleted file mode 100644 index 5bbfe33b..00000000 --- a/conreq/core/api/permissions.py +++ /dev/null @@ -1,18 +0,0 @@ -import typing - -from django.http import HttpRequest -from rest_framework_api_key.models import APIKey -from rest_framework_api_key.permissions import BaseHasAPIKey, KeyParser - - -class HasAPIKey(BaseHasAPIKey): - model = APIKey - key_parser = KeyParser() - - def get_key(self, request: HttpRequest) -> typing.Optional[str]: - # Prefer key in header - header_key = self.key_parser.get(request) - if header_key: - return header_key - # Fallback to key in URL parameters - return request.GET.get("apikey") diff --git a/conreq/core/api/serializers.py b/conreq/core/api/serializers.py deleted file mode 100644 index 85d43391..00000000 --- a/conreq/core/api/serializers.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import serializers - -from conreq.core.manage_users.models import Profile - - -class UserProfileSerializer(serializers.ModelSerializer): - class Meta: - model = Profile - fields = ["language", "externally_authenticated"] - - -class UserSerializer(serializers.ModelSerializer): - profile = UserProfileSerializer(required=True) - - class Meta: - model = get_user_model() - fields = [ - "id", - "last_login", - "is_superuser", - "username", - "first_name", - "last_name", - "email", - "is_staff", - "is_active", - "date_joined", - "groups", - "user_permissions", - "profile", - "auth_token", - ] diff --git a/conreq/core/api/urls.py b/conreq/core/api/urls.py deleted file mode 100644 index 930916ec..00000000 --- a/conreq/core/api/urls.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.urls import path -from rest_framework.authtoken.views import ObtainAuthToken - -from . import views - -urlpatterns = [ - # Default Endpoints - path("user", views.stub), # GET/PUT - path("user/me", views.stub), # GET/PUT - path("user/", views.stub), # GET/PUT/DELETE - path("auth/local", views.LocalAuthentication.as_view()), # POST - path("auth/local/token", ObtainAuthToken.as_view()), # POST/DELETE - path("system", views.stub), # GET - path("system/settings", views.stub), # GET/POST - # TMDB Requests App - path("tmdb/request", views.stub), # GET - path("tmdb/request/", views.stub), # GET/DELETE - path("tmdb/request/tv/", views.RequestTv.as_view()), # POST - path("tmdb/request/movie/", views.RequestMovie.as_view()), # POST - path("tmdb/issue", views.stub), # GET/POST - path("tmdb/issue/", views.stub), # GET/DELETE - path("user//tmdb/issues", views.stub), # GET - path("user//tmdb/requests", views.stub), # GET - # Sonarr/Radarr Connection App - path("sonarr/library", views.stub), # GET - path("sonarr/settings", views.stub), # GET/POST - path("radarr/library", views.stub), # GET - path("radarr/settings", views.stub), # GET/POST - path("system/settings/sonarr", views.stub), # GET/POST - path("system/settings/radarr", views.stub), # GET/POST - # TMDB Connection App - path("tmdb/discover", views.stub), # GET - path("tmdb/discover/tv", views.stub), # GET - path("tmdb/discover/tv/filters", views.stub), # GET - path("tmdb/discover/tv/filters/", views.stub), # GET - path("tmdb/discover/movies", views.stub), # GET - path("tmdb/discover/movies/filters", views.stub), # GET - path("tmdb/discover/movies/filters/", views.stub), # GET - path("tmdb/search", views.stub), # GET - path("tmdb/search/tv", views.stub), # GET - path("tmdb/search/movie", views.stub), # GET - path("tmdb/tv/", views.stub), # GET - path("tmdb/tv//recommendations", views.stub), # GET - path("tmdb/tv/genres", views.stub), # GET - path("tmdb/movie/", views.stub), # GET - path("tmdb/movie//recommendations", views.stub), # GET - path("tmdb/movie/genres", views.stub), # GET - path("tmdb/person/", views.stub), # GET - path("tmdb/regions", views.stub), # GET - path("tmdb/languages", views.stub), # GET - path("tmdb/studio/", views.stub), # GET - path("tmdb/network/", views.stub), # GET - path("tmdb/collection/", views.stub), # GET -] diff --git a/conreq/core/api/views.py b/conreq/core/api/views.py deleted file mode 100644 index 6512e1a5..00000000 --- a/conreq/core/api/views.py +++ /dev/null @@ -1,189 +0,0 @@ -import json - -from django.contrib.auth import authenticate, login -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema -from rest_framework import status -from rest_framework.decorators import api_view -from rest_framework.response import Response -from rest_framework.views import APIView - -from conreq.core.tmdb.discovery import TmdbDiscovery -from conreq.core.user_requests.helpers import radarr_request, sonarr_request - -from .serializers import UserSerializer - - -# Create your views here. -class RequestTv(APIView): - """Request a TV show by TMDB ID.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.msg = {"success": True, "detail": None} - - @swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "seasons": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - ), - "episodes": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - ), - }, - ), - responses={ - status.HTTP_200_OK: openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "success": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - "detail": openapi.Schema( - type=openapi.TYPE_STRING, - ), - }, - ), - }, - ) - def post(self, request, tmdb_id): - """Request a TV show by TMDB ID. Optionally, you can request specific seasons or episodes.""" - tvdb_id = TmdbDiscovery().get_external_ids(tmdb_id, "tv") - request_parameters = json.loads(request.body.decode("utf-8")) - - # Request the show by the TVDB ID - if tvdb_id: - sonarr_request( - tvdb_id["tvdb_id"], - tmdb_id, - request, - request_parameters, - ) - return Response(self.msg) - return Response({"success": False, "detail": "Could not determine TVDB ID."}) - - -class RequestMovie(APIView): - """Request a movie by TMDB ID.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.msg = {"success": True, "detail": None} - - @swagger_auto_schema( - responses={ - status.HTTP_200_OK: openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "success": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - "detail": openapi.Schema( - type=openapi.TYPE_STRING, - ), - }, - ), - }, - ) - def post(self, request, tmdb_id): - """Request a movie by TMDB ID.""" - - # Request the show by the TMDB ID - radarr_request(tmdb_id, request) - return Response(self.msg) - - -class LocalAuthentication(APIView): - """Sign in to an account.""" - - @swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "username": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "password": openapi.Schema( - type=openapi.TYPE_STRING, - ), - }, - ), - responses={ - status.HTTP_200_OK: openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "id": openapi.Schema( - type=openapi.TYPE_INTEGER, - ), - "last_login": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "is_superuser": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - "username": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "first_name": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "last_name": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "email": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "is_staff": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - "is_active": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - "date_joined": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "groups": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - ), - "user_permissions": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - ), - "profile": openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "language": openapi.Schema( - type=openapi.TYPE_STRING, - ), - "externally_authenticated": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - ), - }, - ), - "auth_token": openapi.Schema( - type=openapi.TYPE_STRING, - ), - }, - ), - }, - ) - def post(self, request): - """Authenticate a session using a `username` and `password`. - Requires CSRF tokens on all further insecure requests (POST, PUT, DELETE, PATCH).""" - username = request.data.get("username") - password = request.data.get("password") - user = authenticate(request, username=username, password=password) - if user is not None: - login(request, user) - return Response(UserSerializer(user).data) - - -@api_view(["GET"]) -def stub(request): - """This is a stub for an endpoint that has not yet been developed.""" - return Response({}) diff --git a/conreq/core/arrs/tasks.py b/conreq/core/arrs/tasks.py index a939722a..75decf85 100644 --- a/conreq/core/arrs/tasks.py +++ b/conreq/core/arrs/tasks.py @@ -9,13 +9,13 @@ ARR_REFRESH_INTERNAL = get_str_from_env("ARR_REFRESH_INTERNAL", "*/1") -@db_periodic_task(crontab(minute=ARR_REFRESH_INTERNAL, strict=True)) +@db_periodic_task(crontab(minute=ARR_REFRESH_INTERNAL, strict=True), expires=120) def refresh_radarr_library(): """Checks Radarr for new entries.""" RadarrManager().refresh_library() -@db_periodic_task(crontab(minute=ARR_REFRESH_INTERNAL, strict=True)) +@db_periodic_task(crontab(minute=ARR_REFRESH_INTERNAL, strict=True), expires=120) def refresh_sonarr_library(): """Checks Sonarr for new entries.""" SonarrManager().refresh_library() diff --git a/conreq/core/base/management/commands/preconfig_conreq.py b/conreq/core/base/management/commands/preconfig_conreq.py index 12124cc0..3f0e77c8 100644 --- a/conreq/core/base/management/commands/preconfig_conreq.py +++ b/conreq/core/base/management/commands/preconfig_conreq.py @@ -79,10 +79,12 @@ def setup_sqlite_database(path, name, uid, gid, no_perms): if not os.path.exists(path): print("> Creating database") with sqlite3.connect(path) as cursor: + print("> Optimizing database") + cursor.execute("PRAGMA optimize;") print("> Vacuuming database") - cursor.execute("VACUUM") - print("> Configuring database") - cursor.execute("PRAGMA journal_mode = WAL;") + cursor.execute("VACUUM;") + print("> Reindexing database") + cursor.execute("REINDEX;") if not no_perms and (uid != -1 or gid != -1) and sys.platform == "linux": # pylint: disable=no-member print("> Applying permissions") diff --git a/conreq/core/base/management/commands/run_conreq.py b/conreq/core/base/management/commands/run_conreq.py index 3361d3ab..b098993b 100644 --- a/conreq/core/base/management/commands/run_conreq.py +++ b/conreq/core/base/management/commands/run_conreq.py @@ -1,22 +1,26 @@ +import contextlib import os -import sqlite3 +import sys +from logging import getLogger from multiprocessing import Process +from time import sleep import django +import uvicorn from django.conf import settings from django.core.cache import cache from django.core.management import call_command from django.core.management.base import BaseCommand -from hypercorn.config import Config as HypercornConfig -from hypercorn.run import run as run_hypercorn from conreq.utils.environment import get_debug -HYPERCORN_TOML = os.path.join(getattr(settings, "DATA_DIR"), "hypercorn.toml") +UVICORN_CONFIG = os.path.join(getattr(settings, "DATA_DIR"), "uvicorn.env") DEBUG = get_debug() HUEY_FILENAME = getattr(settings, "HUEY_FILENAME") ACCESS_LOG_FILE = getattr(settings, "ACCESS_LOG_FILE") +_logger = getLogger(__name__) + class Command(BaseCommand): help = "Runs all commands needed to safely start Conreq." @@ -25,6 +29,11 @@ def handle(self, *args, **options): port = options["port"] verbosity = "-v 1" if DEBUG else "-v 0" + # Perform clean-up + if DEBUG: + print("Clearing cache...") + cache.clear() + # Run any preconfiguration tasks if not options["disable_preconfig"]: preconfig_args = [ @@ -40,45 +49,31 @@ def handle(self, *args, **options): if not options["skip_checks"]: call_command("test", "--noinput", "--parallel", "--failfast") - # Perform any debug related clean-up - if DEBUG: - print("Conreq is in DEBUG mode.") - print("Clearing cache...") - cache.clear() - print("Removing stale background tasks...") - self.reset_huey_db() - # Migrate the database call_command("migrate", "--noinput", verbosity) + # Collect static files if not DEBUG: - # Collect static files call_command("collectstatic", "--link", "--clear", "--noinput", verbosity) call_command("compress", "--force", verbosity) - # Run background task management - proc = Process(target=self.start_huey, daemon=True) - proc.start() + huey = Process(target=start_huey) + huey.start() + webserver = Process(target=start_webserver, kwargs={"port": port}) + webserver.start() - # Run the production webserver - if not DEBUG: - config = HypercornConfig() - config.bind = f"0.0.0.0:{port}" - config.websocket_ping_interval = 20 - config.workers = 3 - config.application_path = "conreq.asgi:application" - config.accesslog = ACCESS_LOG_FILE + while True: + if not huey.is_alive(): + _logger.warning("Background task manager has crashed. Restarting...") + huey = Process(target=start_huey, daemon=True) + huey.start() - # Additonal webserver configuration - if os.path.exists(HYPERCORN_TOML): - config.from_toml(HYPERCORN_TOML) + if not webserver.is_alive(): + _logger.warning("Webserver has crashed. Restarting...") + webserver = Process(target=start_webserver, kwargs={"port": port}) + webserver.start() - # Run the webserver - run_hypercorn(config) - - # Run the development webserver - if DEBUG: - call_command("runserver", f"0.0.0.0:{port}") + sleep(5) def add_arguments(self, parser): parser.add_argument( @@ -115,20 +110,58 @@ def add_arguments(self, parser): help="Have Conreq set permissions during preconfig.", ) - @staticmethod - def reset_huey_db(): - """Deletes all entries within the Huey background task database.""" - with sqlite3.connect(HUEY_FILENAME) as cursor: - tables = list( - cursor.execute("select name from sqlite_master where type is 'table'") - ) - cursor.executescript(";".join(["delete from %s" % i for i in tables])) - - @staticmethod - def start_huey(): - """Starts the Huey background task manager.""" - django.setup() - if DEBUG: - call_command("run_huey") - else: - call_command("run_huey", "--quiet") + +def start_webserver(port): + django.setup() + + uvicorn.run( + "conreq.asgi:application", + host="0.0.0.0", + port=port, + ws_ping_interval=10, + workers=1 if DEBUG else (os.cpu_count() or 8), + access_log=ACCESS_LOG_FILE, + reload=DEBUG, + env_file=UVICORN_CONFIG if os.path.exists(UVICORN_CONFIG) else None, + ) + + +def start_huey(): + """Starts the Huey background task manager.""" + django.setup() + print("Starting Huey background task manager...") + + if DEBUG: + call_command("run_huey") + else: + call_command("run_huey", "--quiet") + + +# Patch if on a *nix system +if sys.platform != "win32": + # Monkey patch for https://github.com/Kludex/uvicorn/issues/2679 + from uvicorn import _subprocess + + def subprocess_started( + config, + target, + sockets, + stdin_fileno, + ) -> None: + """ + Called when the child process starts. + + * config - The Uvicorn configuration instance. + * target - A callable that accepts a list of sockets. In practice this will + be the `Server.run()` method. + * sockets - A list of sockets to pass to the server. Sockets are bound once + by the parent process, and then passed to the child processes. + * stdin_fileno - The file number of sys.stdin, so that it can be reattached + to the child process. + """ + config.configure_logging() + + with contextlib.suppress(KeyboardInterrupt): + target(sockets=sockets) + + _subprocess.subprocess_started = subprocess_started diff --git a/conreq/core/base/static/css/posters.css b/conreq/core/base/static/css/posters.css index 05fb5254..b7b295a1 100644 --- a/conreq/core/base/static/css/posters.css +++ b/conreq/core/base/static/css/posters.css @@ -32,9 +32,6 @@ height: var(--poster-height); display: block; transition: transform 0.3s ease, opacity 2.5s, -webkit-filter 0.3s ease; - transition: filter 0.3s ease, transform 0.3s ease, opacity 2.5s; - transition: filter 0.3s ease, transform 0.3s ease, opacity 2.5s, - -webkit-filter 0.3s ease; z-index: 6; position: relative; } @@ -92,7 +89,8 @@ filter: brightness(25%); } -.viewport .poster-container .fa-angle-down { +.viewport .poster-container .fa-angle-down, +.viewport .poster-container .fa-plus { position: absolute; bottom: 0px; color: rgba(255, 255, 255, 0.5); @@ -106,11 +104,36 @@ text-align: center; } +.viewport .poster-container .fa-plus { + right: 5px; + top: calc(var(--poster-height) - 25px); + width: 20px; + height: 20px; + padding: unset; + color: #000000; + background: #e4e4e4; + opacity: 0.65; + align-content: center; + justify-content: center; + font-size: 12px; + line-height: 14px; + border-radius: 20px; + transition: color 0.3s ease, background 0.3s ease, opacity 0.3s ease; + -webkit-filter: drop-shadow(0 0 6px #000000); + filter: drop-shadow(0 0 6px #000000); +} + .viewport .poster-container .fa-angle-down:hover { background-color: rgb(255 255 255 / 10%); color: rgba(255, 255, 255, 0.85); } +.viewport .poster-container .fa-plus:hover { + color: var(--accent-color); + background: #ffffff; + opacity: 1; +} + .requested-by { position: absolute; top: calc(var(--poster-height) - 30px); diff --git a/conreq/core/base/static/css/sidebar.css b/conreq/core/base/static/css/sidebar.css index 50817ae8..53f98cd3 100644 --- a/conreq/core/base/static/css/sidebar.css +++ b/conreq/core/base/static/css/sidebar.css @@ -107,7 +107,7 @@ .sidebar-user .username { display: inline-flex; - width: calc(100% - 50px); + width: calc(100% - 55px); font-weight: 700; } diff --git a/conreq/core/base/static/css/simple_posters.css b/conreq/core/base/static/css/simple_posters.css index 89ce3599..479b0ae1 100644 --- a/conreq/core/base/static/css/simple_posters.css +++ b/conreq/core/base/static/css/simple_posters.css @@ -2,7 +2,8 @@ height: var(--poster-height); } -.viewport .poster-container .fa-angle-down { +.viewport .poster-container .fa-angle-down, +.viewport .poster-container .fa-plus { padding-top: 0px; color: #000000; background: #e4e4e4; @@ -19,11 +20,20 @@ transition: color 0.3s ease, background 0.3s ease, opacity 0.3s ease; } -.viewport .poster-container:hover .fa-angle-down { +.viewport .poster-container .fa-plus { + left: unset; + right: 5px; + bottom: 5px; + transform: none; +} + +.viewport .poster-container:hover .fa-angle-down, +.viewport .poster-container:hover .fa-plus { opacity: 1; } -.viewport .poster-container .fa-angle-down:hover { +.viewport .poster-container .fa-angle-down:hover, +.viewport .poster-container .fa-plus:hover { color: var(--accent-color); background: #ffffff; } diff --git a/conreq/core/base/static/js/events_click.js b/conreq/core/base/static/js/events_click.js index 9712376a..96c420a9 100644 --- a/conreq/core/base/static/js/events_click.js +++ b/conreq/core/base/static/js/events_click.js @@ -90,7 +90,7 @@ var quick_info_btn_click_event = async function () { var content_modal_click_event = async function () { $( - ".series-modal-btn, .content-preview-modal-btn, .report-selection-modal-btn" + ".series-modal-btn, .content-preview-modal-btn, .report-selection-modal-btn", ).each(async function () { let current_btn = $(this); if (!current_btn.hasClass("loaded")) { @@ -99,11 +99,11 @@ var content_modal_click_event = async function () { tmdb_id: $(this).data("tmdb-id"), content_type: $(this).data("content-type"), report_modal: $(this).hasClass( - "report-selection-modal-btn" + "report-selection-modal-btn", ), }; generate_modal( - $(this).data("modal-url") + "?" + $.param(params) + $(this).data("modal-url") + "?" + $.param(params), ); }); current_btn.addClass("loaded"); @@ -111,6 +111,39 @@ var content_modal_click_event = async function () { }); }; +var quick_request_click_event = async function () { + $(".quick-request-btn").each(async function () { + let btn = $(this); + btn.unbind("click"); + btn.click(async function () { + let params = { + tmdb_id: btn.data("tmdb-id"), + content_type: btn.data("content-type"), + seasons: null, + episode_ids: null, + }; + + // Prevent the user from spamming the request button + if (ongoing_request == btn) { + return false; + } else { + ongoing_request = btn; + } + + // Request the content + post_json(btn.data("request-url"), params, function () { + requested_toast_message(); + console.log(btn); + btn.remove(); + ongoing_request = null; + }).fail(async function () { + conreq_no_response_toast_message(); + ongoing_request = null; + }); + }); + }); +}; + var create_report_modal_click_event = async function () { $(".report-modal-btn").each(async function () { $(this).unbind("click"); @@ -124,7 +157,7 @@ var create_report_modal_click_event = async function () { if (params.content_type == "movie") { report_selection = null; generate_modal( - $(this).data("modal-url") + "?" + $.param(params) + $(this).data("modal-url") + "?" + $.param(params), ); } @@ -137,7 +170,7 @@ var create_report_modal_click_event = async function () { // All non-special seasons were checkboxed if (current_selection == true) { generate_modal( - $(this).data("modal-url") + "?" + $.param(params) + $(this).data("modal-url") + "?" + $.param(params), ); } @@ -149,7 +182,7 @@ var create_report_modal_click_event = async function () { ) { report_selection = current_selection; generate_modal( - $(this).data("modal-url") + "?" + $.param(params) + $(this).data("modal-url") + "?" + $.param(params), ); } @@ -239,7 +272,7 @@ var row_title_click_event = async function () { // Checkmark all related episodes let episode_container = $( - season_checkbox.data("all-suboptions-container") + season_checkbox.data("all-suboptions-container"), ); let episode_checkboxes = episode_container.find("input"); episode_checkboxes.prop("checked", season_checkbox.prop("checked")); @@ -251,7 +284,7 @@ var row_checkbox_click_event = async function () { // Checkmark all related episodes let season_checkbox = $(this); let episode_container = $( - season_checkbox.data("all-suboptions-container") + season_checkbox.data("all-suboptions-container"), ); let episode_checkboxes = episode_container.find("input"); episode_checkboxes.prop("checked", season_checkbox.prop("checked")); @@ -301,7 +334,7 @@ var row_suboption_checkbox_click_event = async function () { // Checkmark the season box if every episode is checked else { let all_episodes_container = $( - this.parentElement.parentElement.parentElement.parentElement + this.parentElement.parentElement.parentElement.parentElement, ); let episode_checkboxes = all_episodes_container.find("input"); let checkmarked_episode_checkboxes = @@ -455,7 +488,7 @@ var server_settings_dropdown_click_event = async function () { change_server_setting(setting_name, dropdown_id).then( async function () { generate_viewport(false); - } + }, ); }); }; diff --git a/conreq/core/base/static/js/events_generic.js b/conreq/core/base/static/js/events_generic.js index e0429966..68269be2 100644 --- a/conreq/core/base/static/js/events_generic.js +++ b/conreq/core/base/static/js/events_generic.js @@ -20,14 +20,14 @@ $(document).ready(async function () { // Server Settings $( - 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin' + 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin', ).each(async function () { let setting_name = $(this).data("setting-name"); let current_value = $(this).val(); previous_admin_settings[setting_name] = current_value; }); $( - 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin' + 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin', ).on("keypress", function (e) { let setting_name = $(this).data("setting-name"); let current_value = $(this).val(); @@ -37,7 +37,7 @@ $(document).ready(async function () { } }); $( - 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin' + 'input[type="text"].settings-item.admin, input[type="url"].settings-item.admin', ).focusout(async function () { let setting_name = $(this).data("setting-name"); let current_value = $(this).val(); @@ -69,6 +69,7 @@ $(document).ready(async function () { } content_modal_click_event(); + quick_request_click_event(); // Infinite Scroller if ($(".viewport-container>.infinite-scroll").length) { @@ -95,6 +96,7 @@ $(document).ready(async function () { masonry_grid.on("append.infiniteScroll", async function () { cull_old_posters(); content_modal_click_event(); + quick_request_click_event(); }); infinite_scroller_created = true; @@ -137,6 +139,7 @@ $(document).ready(async function () { // More Info page events request_btn_click_event(); content_modal_click_event(); + quick_request_click_event(); create_report_modal_click_event(); quick_info_btn_click_event(); more_info_poster_popup_click_event(); @@ -179,7 +182,7 @@ $(document).ready(async function () { ]); }); }); - } + }, ); $(".viewport-container, .viewport-container-top").on( @@ -189,12 +192,13 @@ $(document).ready(async function () { if (new_element.is(".carousel.auto-construct")) { viewport_carousel_constructor(); content_modal_click_event(); + quick_request_click_event(); } if (new_element.hasClass("auto-uncollapse")) { new_element.collapse("show"); } server_settings_dropdown_click_event(); - } + }, ); $(".sidebar").on("loaded", async function () { diff --git a/conreq/core/base/static/js/modal.js b/conreq/core/base/static/js/modal.js index dbfd1ec5..4d32c308 100644 --- a/conreq/core/base/static/js/modal.js +++ b/conreq/core/base/static/js/modal.js @@ -20,6 +20,7 @@ var generate_modal = async function (modal_url) { // Add click events request_btn_click_event(); content_modal_click_event(); + quick_request_click_event(); modal_select_all_btn_click_event(); modal_expand_btn_click_event(); row_title_click_event(); diff --git a/conreq/core/base/static/js/viewport.js b/conreq/core/base/static/js/viewport.js index a5a90ea2..805a9280 100644 --- a/conreq/core/base/static/js/viewport.js +++ b/conreq/core/base/static/js/viewport.js @@ -36,7 +36,7 @@ let update_active_tab = async function () { // Updates the page name let update_page_title = async function (viewport_selector) { let page_name = DOMPurify.sanitize( - $(viewport_selector + ">.page-name").val() + $(viewport_selector + ">.page-name").val(), ); let app_name = $("#app-name").val(); if (page_name) { @@ -68,7 +68,7 @@ let cached_viewport_exists = function () { let display_cached_viewport = async function () { let new_viewport = "main[data-url='" + get_window_location() + "']"; let old_viewport = $( - "main:not([data-url='" + get_window_location() + "'])" + "main:not([data-url='" + get_window_location() + "'])", ); $(new_viewport).trigger("prepare-cached"); update_active_tab(); @@ -97,7 +97,7 @@ let prepare_viewport = async function (viewport_selector) { let get_viewport = async function ( location, viewport_selector, - success = function () {} + success = function () {}, ) { $(viewport_selector).trigger("url-changed"); @@ -120,7 +120,7 @@ let get_viewport = async function ( await destroy_viewport(viewport_selector); $(viewport_selector + ">*").remove(); $(viewport_selector).append( - "

Could not connect to the server!

" + "

Could not connect to the server!

", ); $(viewport_selector + ">h1").css("text-align", "center"); select_active_viewport(viewport_selector); @@ -160,7 +160,7 @@ var generate_viewport = async function (standard_viewport_load = true) { window.history.replaceState( {}, null, - $(".nav-tab a").attr("href") + $(".nav-tab a").attr("href"), ); window_location = window.location.hash.split("#")[1]; } @@ -192,14 +192,14 @@ var generate_viewport = async function (standard_viewport_load = true) { // Display the new content $(viewport_selector + ">.loading-animation-container").hide(); $( - viewport_selector + ">*:not(.loading-animation-container)" + viewport_selector + ">*:not(.loading-animation-container)", ).show(); // Set scroll position if (standard_viewport_load) { $(viewport_selector).scrollTop(0); } - } + }, ); // If the page is taking too long to load, show a loading animation @@ -209,7 +209,7 @@ var generate_viewport = async function (standard_viewport_load = true) { select_active_viewport(viewport_selector); $(viewport_selector + ">.loading-animation-container").show(); $( - viewport_selector + ">*:not(.loading-animation-container)" + viewport_selector + ">*:not(.loading-animation-container)", ).hide(); } }, 500); diff --git a/conreq/core/base/tasks.py b/conreq/core/base/tasks.py index ee7803de..b2eea7cd 100644 --- a/conreq/core/base/tasks.py +++ b/conreq/core/base/tasks.py @@ -1,4 +1,3 @@ -import os import sqlite3 from django.conf import settings @@ -12,9 +11,8 @@ HUEY_FILENAME = getattr(settings, "HUEY_FILENAME") -@db_periodic_task(crontab(minute="0", hour="0", strict=True)) -def vacuum_huey_sqlite_db(): - """Periodically preforms a SQLITE vacuum on the background task database.""" +@db_periodic_task(crontab(minute="0", hour="0", strict=True), expires=120) +def huey_db_maintenance(): with sqlite3.connect(HUEY_FILENAME) as cursor: cursor.execute( # Only keep the 1000 latest tasks @@ -31,13 +29,16 @@ def vacuum_huey_sqlite_db(): """ ) with sqlite3.connect(HUEY_FILENAME) as cursor: - cursor.execute("VACUUM") + cursor.execute("PRAGMA optimize;") + cursor.execute("VACUUM;") + cursor.execute("REINDEX;") if DB_ENGINE == "SQLITE3": - @db_periodic_task(crontab(minute="0", hour="0", strict=True)) - def vacuum_conreq_sqlite_db(): - """Periodically performs any cleanup tasks needed for the default database.""" + @db_periodic_task(crontab(minute="0", hour="0", strict=True), expires=120) + def conreq_db_maintenance(): with connection.cursor() as cursor: - cursor.execute("VACUUM") + cursor.execute("PRAGMA optimize;") + cursor.execute("VACUUM;") + cursor.execute("REINDEX;") diff --git a/conreq/core/base/templates/primary/sidebar.html b/conreq/core/base/templates/primary/sidebar.html index b540f91b..d2498739 100644 --- a/conreq/core/base/templates/primary/sidebar.html +++ b/conreq/core/base/templates/primary/sidebar.html @@ -166,18 +166,8 @@ Code Outline - - {% endif %} - \ No newline at end of file + diff --git a/conreq/core/base/validators.py b/conreq/core/base/validators.py index b5270bff..0ea75c37 100644 --- a/conreq/core/base/validators.py +++ b/conreq/core/base/validators.py @@ -9,29 +9,30 @@ class ExtendedURLValidator(URLValidator): """URL validator that supports hostnames (ex. https://sonarr:8000)""" - # pylint: disable=too-few-public-methods - ul = URLValidator.ul + # IP patterns ipv4_re = URLValidator.ipv4_re ipv6_re = URLValidator.ipv6_re hostname_re = URLValidator.hostname_re domain_re = URLValidator.domain_re + tld_re = URLValidator.tld_re - tld_re = ( - r"\.?" # OPTIONAL dot (allows for hostnames) - r"(?!-)" # can't start with a dash - r"(?:[a-z" + ul + "-]{2,63}" # domain label - r"|xn--[a-z0-9]{1,59})" # or punycode label - r"(? + + +
@@ -45,4 +50,4 @@
{{card.overview}}
- \ No newline at end of file + diff --git a/conreq/core/user_requests/tasks.py b/conreq/core/user_requests/tasks.py index 4219f223..cbb81be8 100644 --- a/conreq/core/user_requests/tasks.py +++ b/conreq/core/user_requests/tasks.py @@ -7,7 +7,7 @@ _logger = log.get_logger(__name__) -@db_task() +@db_task(retries=25, retry_delay=300) def sonarr_request_background_task(tvdb_id, request_params, sonarr_params): """Function that can be run in the background to request something on Sonarr""" try: @@ -55,7 +55,7 @@ def sonarr_request_background_task(tvdb_id, request_params, sonarr_params): ) -@db_task() +@db_task(retries=25, retry_delay=300) def radarr_request_background_task(tmdb_id, radarr_params): """Function that can be run in the background to request something on Radarr""" try: diff --git a/conreq/settings.py b/conreq/settings.py index 0ee4038b..c76df17d 100644 --- a/conreq/settings.py +++ b/conreq/settings.py @@ -82,8 +82,7 @@ SILKY_PYTHON_PROFILER = True SILKY_PYTHON_PROFILER_BINARY = True SILKY_PYTHON_PROFILER_RESULT_PATH = METRICS_DIR -HTML_MINIFY = True -WHITENOISE_MAX_AGE = 31536000 if not DEBUG else 0 +SERVESTATIC_MAX_AGE = 0 if DEBUG else 31536000 COMPRESS_OUTPUT_DIR = "minified" COMPRESS_OFFLINE = True COMPRESS_STORAGE = "compressor.storage.BrotliCompressorFileStorage" @@ -96,11 +95,20 @@ "name": "huey", # DB name for huey. "huey_class": "huey.SqliteHuey", # Huey implementation to use. "filename": HUEY_FILENAME, # Sqlite filename - "results": True, # Store return values of tasks. + "results": True, # Whether to return values of tasks. + "store_none": False, # Whether to store results of tasks that return None. "immediate": False, # If True, run tasks synchronously. "strict_fifo": True, # Utilize Sqlite AUTOINCREMENT to have unique task IDs + "timeout": 10, # Seconds to wait when reading from the DB. + "connection": { + "isolation_level": "IMMEDIATE", # Use immediate transactions to allow sqlite to respect `timeout`. + "cached_statements": 2000, # Number of pages to keep in memory. + }, "consumer": { - "workers": 20, + "workers": os.cpu_count() or 8, # Number of worker processes/threads. + "worker_type": "thread", # "thread" or "process" + "initial_delay": 0.25, # Smallest polling interval + "check_worker_health": True, # Whether to monitor worker health. }, } @@ -224,18 +232,6 @@ LANGUAGE_COOKIE_HTTPONLY = True # Do not allow JS to access cookie -# API Settings -REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", - ], - "DEFAULT_PERMISSION_CLASSES": [ - "conreq.core.api.permissions.HasAPIKey", - ], -} - - # settings.json (old) -> settings.env SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") if os.path.exists(SETTINGS_FILE): @@ -270,7 +266,7 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - "whitenoise.runserver_nostatic", + "servestatic.runserver_nostatic", "django.contrib.staticfiles", *list_modules(CORE_DIR, prefix="conreq.core."), "encrypted_fields", # Allow for encrypted text in the DB @@ -279,15 +275,11 @@ "huey.contrib.djhuey", # Queuing background tasks "compressor", # Minifies CSS/JS files "url_or_relative_url_field", # Validates relative URLs - "rest_framework", # OpenAPI Framework - "rest_framework_api_key", # API Key Manager - "rest_framework.authtoken", # API User Authentication *list_modules(APPS_DIR), # User Installed Apps ] MIDDLEWARE = [ - "compression_middleware.middleware.CompressionMiddleware", "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", # Serve static files through Django securely + "servestatic.middleware.ServeStaticMiddleware", # Serve static files through Django securely "django.middleware.gzip.GZipMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.http.ConditionalGetMiddleware", @@ -296,8 +288,6 @@ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "htmlmin.middleware.HtmlMinifyMiddleware", # Compresses HTML files - "htmlmin.middleware.MarkRequestMiddleware", # Marks the request as minified ] @@ -306,8 +296,6 @@ # Performance analysis tools INSTALLED_APPS.append("silk") MIDDLEWARE.append("silk.middleware.SilkyMiddleware") - # API docs generator - INSTALLED_APPS.append("drf_yasg") # URL Routing and Page Rendering @@ -358,18 +346,24 @@ "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(DATA_DIR, "db.sqlite3"), "OPTIONS": { - "timeout": 3, # 3 second query timeout + "init_command": ( + "PRAGMA foreign_keys = ON;" + "PRAGMA journal_mode = WAL;" + "PRAGMA synchronous = NORMAL;" + "PRAGMA busy_timeout = 10000;" + "PRAGMA temp_store = MEMORY;" + "PRAGMA mmap_size = 134217728;" + "PRAGMA journal_size_limit = 67108864;" + "PRAGMA cache_size = 2000;" + ), + "transaction_mode": "IMMEDIATE", }, } } CACHES = { "default": { - "BACKEND": "diskcache.DjangoCache", + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", "LOCATION": os.path.join(DATA_DIR, "cache"), - "TIMEOUT": 300, # Django setting for default timeout of each key. - "SHARDS": 8, # Number of "sharded" cache dbs to create - "DATABASE_TIMEOUT": 0.25, # 250 milliseconds - "OPTIONS": {"size_limit": 2**30}, # 1 gigabyte } } diff --git a/conreq/urls.py b/conreq/urls.py index bc603543..b05e288f 100644 --- a/conreq/urls.py +++ b/conreq/urls.py @@ -47,7 +47,6 @@ path("search/", include("conreq.core.search.urls")), path("manage_users/", include("conreq.core.manage_users.urls")), path("server_settings/", include("conreq.core.server_settings.urls")), - path("api/v1/", include("conreq.core.api.urls")), ] # Add User Installed Apps URLS @@ -62,52 +61,6 @@ urlpatterns.append(path("admin/docs/", include("django.contrib.admindocs.urls"))) urlpatterns.append(path("admin/", admin.site.urls)) - # Django Rest Framework documentation (Swagger and Redoc) - # pylint: disable=ungrouped-imports - from django.urls import re_path - from drf_yasg import openapi - from drf_yasg.views import get_schema_view - from rest_framework import permissions - - SchemaView = get_schema_view( - openapi.Info( - title="Conreq API Endpoints", - default_version="v1", - description=""" - Outline for all endpoints available within this Conreq instance. - - All endpoints require an API key either in **HTTP Header (Authorization: Api-Key)** or in the **URL Parameter (apikey)**. - - Token Authentication is performed using **HTTP Header (Authorization: Token)**. Session Authentication can alternatively be performed. - """, - contact=openapi.Contact(email="archiethemonger@gmail.com"), - license=openapi.License(name="GPL-3.0 License"), - ), - public=True, - permission_classes=[permissions.AllowAny], - ) - - docs_urlpatterns = [ - re_path( - r"^swagger(?P\.json|\.yaml)$", - SchemaView.without_ui(cache_timeout=0), - name="schema-json", - ), - re_path( - r"^swagger/$", - SchemaView.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - re_path( - r"^redoc/$", - SchemaView.with_ui("redoc", cache_timeout=0), - name="schema-redoc", - ), - ] - - for pattern in docs_urlpatterns: - urlpatterns.append(pattern) - # Wrap the urlpatterns in BASE_URL if required if BASE_URL: diff --git a/requirements/main.txt b/requirements/main.txt index 6922f937..1f82a398 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -1,31 +1,25 @@ -channels[daphne]==4.0.0 -diskcache==5.6.3 -django==3.2.25 -django-cleanup==8.1.0 -django-compression-middleware==0.5.0 -django-compressor==4.4 -django-htmlmin==0.11.0 -django-model-utils==4.5.1 +channels[daphne]==4.3.2 +django==5.2.8 +django-cleanup==9.0.0 +django-compressor==4.6 +django-model-utils==5.0.0 django-searchable-encrypted-fields==0.2.1 -django-silk==5.1.0 -django-solo==2.2.0 +django-silk==5.4.3 +django-solo==2.4.0 django-url-or-relative-url-field==0.2.0 -djangorestframework==3.15.1 -djangorestframework-api-key==3.0.0 -docutils==0.21.2 -drf-yasg==1.21.7 -huey==2.5.1 -hypercorn[h3]==0.17.3 -jsonfield==3.1.0 -markdown==3.6.0 -pwned-passwords-django==2.1 +huey==2.5.4 +jsonfield==3.2.0 +markdown==3.10.0 +pwned-passwords-django==5.2.0 pyarr==5.2.0 -pymysql==1.1.1 -python-dotenv==1.0.1 +pymysql==1.1.2 +python-dotenv==1.2.1 strsim==0.0.3 titlecase==2.4.1 tmdbsimple==2.9.1 -Twisted[tls,http2]==24.3.0 -tzlocal==5.2 -whitenoise[brotli]==6.7.0 +Twisted[tls,http2]==25.5.0 +tzlocal==5.3.1 +servestatic[brotli]==3.1.0 +uvicorn[standard]==0.38.0 attrs +cffi