├── mdblistarr ├── mdblist │ ├── __init__.py │ ├── urls.py.1 │ ├── urls.py │ ├── urls.py.2 │ ├── asgi.py │ ├── wsgi.py │ └── settings.py ├── mdblistrr │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── templatetags │ │ ├── __init__.py │ │ └── version.py │ ├── apps.py │ ├── log.py │ ├── admin.py │ ├── urls.py │ ├── connect.py │ ├── models.py │ ├── arr.py │ ├── cron.py │ └── views.py ├── version ├── manage.py └── templates │ ├── log.html │ ├── layout.html │ └── index.html ├── .dockerignore ├── .gitignore ├── requirements.txt ├── Dockerfile ├── django-entrypoint.sh └── README.md /mdblistarr/mdblist/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mdblistarr/version: -------------------------------------------------------------------------------- 1 | # This is placeholder for build version number -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | venv/ 6 | .env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | __pycache__ 5 | db.sqlite3 6 | .DS_Store 7 | @eaDir 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.19 2 | django-crontab==0.7.1 3 | lxml==5.3.1 4 | requests==2.32.3 5 | retrying==1.3.4 -------------------------------------------------------------------------------- /mdblistarr/mdblist/urls.py.1: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MdblistrrConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'mdblistrr' 7 | -------------------------------------------------------------------------------- /mdblistarr/mdblist/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('', include('mdblistrr.urls')), 7 | ] 8 | -------------------------------------------------------------------------------- /mdblistarr/mdblist/urls.py.2: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('', include('mdblistrr.urls')), 7 | ] 8 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/log.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from .models import Log 3 | 4 | def log_view(request): 5 | logs = Log.objects.filter().order_by('-date')[:200] 6 | return render(request, "log.html", {'logs': logs, }) 7 | 8 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/templatetags/version.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | import time 3 | import os 4 | 5 | register = template.Library() 6 | 7 | @register.simple_tag 8 | def version_date(): 9 | return f"2.{time.strftime('%m%d', time.gmtime(os.path.getmtime('./version')))}" 10 | -------------------------------------------------------------------------------- /mdblistarr/mdblist/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for mdblist project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mdblist.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /mdblistarr/mdblist/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for mdblist project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mdblist.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Preferences, Log 3 | 4 | class PreferencesAdmin(admin.ModelAdmin): 5 | list_display = ("name", "value", ) 6 | search_fields = ('name', 'value', ) 7 | admin.site.register(Preferences, PreferencesAdmin) 8 | 9 | class LogAdmin(admin.ModelAdmin): 10 | list_display = ("date", "provider", "status", "text", ) 11 | search_fields = ('date', 'provider', 'status', ) 12 | admin.site.register(Log, LogAdmin) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | ENV PYTHONUNBUFFERED 1 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | WORKDIR /usr/src/app 5 | COPY ./requirements.txt . 6 | COPY ./mdblistarr /usr/src/app/ 7 | RUN pip install --upgrade pip 8 | RUN pip install -r requirements.txt 9 | RUN apt-get update 10 | RUN apt-get -y install cron 11 | RUN touch /var/log/cron.log 12 | RUN mkdir -p /usr/src/db/ 13 | ENV PORT 5353 14 | EXPOSE 5353 15 | COPY ./django-entrypoint.sh / 16 | RUN chmod +x /django-entrypoint.sh 17 | CMD ["/django-entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import home_view 3 | from . import views 4 | from .cron import post_radarr_payload 5 | from .log import log_view 6 | 7 | urlpatterns = [ 8 | path('', home_view, name='home_view'), 9 | path('log', log_view, name='log_view'), 10 | # URLs for AJAX test connection endpoints 11 | path('test_radarr_connection/', views.test_radarr_connection, name='test_radarr_connection'), 12 | path('test_sonarr_connection/', views.test_sonarr_connection, name='test_sonarr_connection'), 13 | ] -------------------------------------------------------------------------------- /mdblistarr/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mdblist.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /django-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Cron 3 | /usr/sbin/cron -l 2 4 | # Django 5 | cp /usr/src/app/mdblist/urls.py.1 /usr/src/app/mdblist/urls.py 6 | find . -path "*/migrations/*.py" -not -name "__init__.py" -delete 7 | find . -path "*/migrations/*.pyc" -delete 8 | python /usr/src/app/manage.py makemigrations 9 | python /usr/src/app/manage.py migrate 10 | python /usr/src/app/manage.py shell << EOF 11 | from django.contrib.auth.models import User; 12 | if User.objects.filter(username = 'admin').first() is None: 13 | User.objects.create_superuser('admin', 'admin@mdblistarr', 'admin') 14 | EOF 15 | cp /usr/src/app/mdblist/urls.py.2 /usr/src/app/mdblist/urls.py 16 | python /usr/src/app/manage.py crontab add 17 | python /usr/src/app/manage.py runserver 0.0.0.0:$PORT 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mdblistarr 2 | 3 | Companion app for [mdblist.com](https://mdblist.com) for better Radarr and Sonarr integration. 4 | 5 | ## Docker Hub image 6 | 7 | [linaspurinis/mdblistarr](https://hub.docker.com/r/linaspurinis/mdblistarr) 8 | 9 | ## App Configuration Screen 10 | 11 | ![image](https://github.com/user-attachments/assets/cdd58b1a-4b55-464d-84dd-55246ba6a096) 12 | 13 | ## MDBListarr 14 | 15 | ```sh 16 | git clone --branch v2-beta git@github.com:linaspurinis/mdblistarr.git 17 | docker build -t mdblistarr . 18 | docker run -e PORT=5353 -p 5353:5353 mdblistarr 19 | ``` 20 | 21 | ``` 22 | version: '3' 23 | services: 24 | mdblistarr: 25 | container_name: mdblistarr 26 | image: linaspurinis/mdblistarr:v2-beta 27 | environment: 28 | - PORT=5353 29 | volumes: 30 | - db:/usr/src/db/ 31 | ports: 32 | - '5353:5353' 33 | volumes: 34 | db: 35 | ``` 36 | -------------------------------------------------------------------------------- /mdblistarr/templates/log.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 | 5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for log in logs %} 20 | 21 | 23 | 25 | 26 | {% endfor %} 27 | 28 | 29 |
TypeStatusDateLog
{% if log.provider == 1 %}Radarr{% elif log.provider == 2 %}Sonarr{% elif log.provider == 3 %}Config{% else %}{{ log.provider }}{% endif %} 22 | {% if log.status == 1 %}Success{% else %}Warning{% endif %}{{ log.date|date:"Y-m-d H:i:s" }} 24 | {{log.text}}
30 | 31 |
32 | 33 | {% endblock %} -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/connect.py: -------------------------------------------------------------------------------- 1 | import logging, time, json, re, requests 2 | from urllib.parse import urlparse 3 | from retrying import retry 4 | from requests.exceptions import RequestException, JSONDecodeError, ConnectionError 5 | from lxml import html 6 | 7 | logging.basicConfig(format='%(asctime)s severity=%(levelname)s filename=%(filename)s line=%(lineno)s message="%(message)s"', level=logging.INFO) 8 | 9 | headers = { 10 | 'accept':'*/*', 11 | 'accept-encoding':'gzip, deflate, br', 12 | 'accept-language':'en-GB,en;q=0.9,en-US;q=0.8,hi;q=0.7,la;q=0.6', 13 | 'cache-control':'no-cache', 14 | 'dnt':'1', 15 | 'pragma':'no-cache', 16 | 'referer':'https', 17 | 'sec-fetch-mode':'no-cors', 18 | 'sec-fetch-site':'cross-site', 19 | 'user-agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', 20 | } 21 | 22 | class Connect: 23 | def __init__(self): 24 | self.session = requests.Session() 25 | self.trace_mode = False 26 | 27 | def get_html(self, url, headers=headers, params=None, cookies=None): 28 | return html.fromstring(self.get(url, headers=headers, params=params, cookies=cookies).content) 29 | 30 | def get_json(self, url, json=None, headers=None, params=None, cookies=None): 31 | return self.get(url, json=json, headers=headers, params=params, cookies=cookies).json() 32 | 33 | @retry(stop_max_attempt_number=6, wait_fixed=10000) 34 | def get(self, url, json=None, headers=None, params=None, cookies=None): 35 | return self.session.get(url, json=json, headers=headers, params=params, cookies=cookies) 36 | 37 | def get_image_encoded(self, url): 38 | return base64.b64encode(self.get(url).content).decode('utf-8') 39 | 40 | def post_html(self, url, data=None, json=None, headers=None, cookies=None): 41 | return html.fromstring(self.post(url, data=data, json=json, headers=headers, cookies=cookies).content) 42 | 43 | def post_json(self, url, data=None, json=None, headers=None, params=None, cookies=None): 44 | try: 45 | response = self.post(url, data=data, json=json, headers=headers, params=params, cookies=cookies) 46 | if not response.text.strip(): 47 | return {"error": "Empty response from server", "status_code": response.status_code} 48 | try: 49 | return response.json() 50 | except JSONDecodeError: 51 | return { 52 | "error": "Invalid POST response", 53 | "status_code": response.status_code, 54 | "raw_response": response.text[:500] # Limit output for debugging 55 | } 56 | except ConnectionError as e: 57 | return {"error": "Connection failed", "exception": str(e)} 58 | except RequestException as e: 59 | return {"error": "Request failed", "exception": str(e)} 60 | 61 | @retry(stop_max_attempt_number=6, wait_fixed=10000) 62 | def post(self, url, data=None, json=None, headers=None, params=None, cookies=None): 63 | return self.session.post(url, data=data, json=json, params=params, headers=headers, cookies=cookies) 64 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2025-02-28 09:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='InstanceChangeLog', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('instance_type', models.CharField(choices=[('radarr', 'Radarr'), ('sonarr', 'Sonarr')], max_length=10)), 19 | ('instance_id', models.IntegerField()), 20 | ('event_type', models.CharField(choices=[('added', 'Added'), ('deleted', 'Deleted'), ('name_changed', 'Name Changed')], max_length=20)), 21 | ('old_value', models.CharField(blank=True, max_length=100, null=True)), 22 | ('new_value', models.CharField(blank=True, max_length=100, null=True)), 23 | ('timestamp', models.DateTimeField(auto_now_add=True)), 24 | ('processed', models.BooleanField(default=False)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='Log', 29 | fields=[ 30 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 31 | ('date', models.DateTimeField()), 32 | ('status', models.IntegerField()), 33 | ('provider', models.IntegerField()), 34 | ('text', models.TextField()), 35 | ], 36 | options={ 37 | 'verbose_name_plural': 'log', 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='Preferences', 42 | fields=[ 43 | ('id', models.AutoField(primary_key=True, serialize=False)), 44 | ('name', models.CharField(max_length=255, unique=True)), 45 | ('value', models.CharField(max_length=255, null=True)), 46 | ], 47 | options={ 48 | 'verbose_name_plural': 'preferences', 49 | }, 50 | ), 51 | migrations.CreateModel( 52 | name='RadarrInstance', 53 | fields=[ 54 | ('id', models.AutoField(primary_key=True, serialize=False)), 55 | ('name', models.CharField(max_length=255)), 56 | ('url', models.CharField(max_length=255)), 57 | ('apikey', models.CharField(max_length=255)), 58 | ('quality_profile', models.CharField(max_length=255)), 59 | ('root_folder', models.CharField(max_length=255)), 60 | ('created_at', models.DateTimeField(auto_now_add=True)), 61 | ], 62 | ), 63 | migrations.CreateModel( 64 | name='SonarrInstance', 65 | fields=[ 66 | ('id', models.AutoField(primary_key=True, serialize=False)), 67 | ('name', models.CharField(max_length=255)), 68 | ('url', models.CharField(max_length=255)), 69 | ('apikey', models.CharField(max_length=255)), 70 | ('quality_profile', models.CharField(max_length=255)), 71 | ('root_folder', models.CharField(max_length=255)), 72 | ('created_at', models.DateTimeField(auto_now_add=True)), 73 | ], 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /mdblistarr/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% load version %} 2 | 3 | 4 | 5 | 6 | 23 | MDBListarr client for MDBList.com 24 | 25 | 26 | 27 |
28 | 34 | 35 | 53 |
54 | 55 | {% block body %} 56 | {% endblock %} 57 |
58 |
59 | 60 |
61 |
62 | 63 | 64 | 77 | 78 | -------------------------------------------------------------------------------- /mdblistarr/mdblist/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mdblist project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-&ru)tr*qud#(%0i4%!8u5ime_2s@f8%kxa*y=iiad48ysu+(t-' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = False 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'mdblistrr.apps.MdblistrrConfig', # new 41 | 'django_crontab', 42 | ] 43 | 44 | CRONJOBS = [ 45 | ('11 10 * * *', 'mdblistrr.cron.post_radarr_payload'), 46 | ('11 11 * * *', 'mdblistrr.cron.post_sonarr_payload'), 47 | ('*/5 * * * *', 'mdblistrr.cron.get_mdblist_queue_to_arr'), 48 | ('*/15 * * * *', 'mdblistrr.cron.process_instance_changes'), 49 | ] 50 | 51 | MIDDLEWARE = [ 52 | 'django.middleware.security.SecurityMiddleware', 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.middleware.csrf.CsrfViewMiddleware', 56 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 57 | 'django.contrib.messages.middleware.MessageMiddleware', 58 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 59 | ] 60 | 61 | ROOT_URLCONF = 'mdblist.urls' 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = 'mdblist.wsgi.application' 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 84 | 85 | DATABASES = { 86 | 'default': { 87 | 'ENGINE': 'django.db.backends.sqlite3', 88 | 'NAME': '/usr/src/db/db.sqlite3', 89 | } 90 | } 91 | 92 | 93 | # Password validation 94 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 105 | }, 106 | { 107 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 108 | }, 109 | ] 110 | 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 114 | 115 | LANGUAGE_CODE = 'en-us' 116 | 117 | TIME_ZONE = 'UTC' 118 | 119 | USE_I18N = True 120 | 121 | USE_TZ = True 122 | 123 | 124 | # Static files (CSS, JavaScript, Images) 125 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 126 | 127 | STATIC_URL = 'static/' 128 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 129 | 130 | # Default primary key field type 131 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 132 | 133 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 134 | -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/models.py: -------------------------------------------------------------------------------- 1 | # models.py 2 | from django.db import models 3 | from django.dispatch import receiver 4 | from django.db.models.signals import pre_save, post_save, pre_delete 5 | 6 | class Preferences(models.Model): 7 | id = models.AutoField(primary_key=True) 8 | name = models.CharField(max_length=255, unique=True) 9 | value = models.CharField(max_length=255, null=True) 10 | 11 | class Meta: 12 | verbose_name_plural = "preferences" 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | class RadarrInstance(models.Model): 18 | id = models.AutoField(primary_key=True) 19 | name = models.CharField(max_length=255) 20 | url = models.CharField(max_length=255) 21 | apikey = models.CharField(max_length=255) 22 | quality_profile = models.CharField(max_length=255) 23 | root_folder = models.CharField(max_length=255) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | 26 | def __str__(self): 27 | return self.name 28 | 29 | class SonarrInstance(models.Model): 30 | id = models.AutoField(primary_key=True) 31 | name = models.CharField(max_length=255) 32 | url = models.CharField(max_length=255) 33 | apikey = models.CharField(max_length=255) 34 | quality_profile = models.CharField(max_length=255) 35 | root_folder = models.CharField(max_length=255) 36 | created_at = models.DateTimeField(auto_now_add=True) 37 | 38 | def __str__(self): 39 | return self.name 40 | 41 | class InstanceChangeLog(models.Model): 42 | INSTANCE_TYPES = [ 43 | ('radarr', 'Radarr'), 44 | ('sonarr', 'Sonarr'), 45 | ] 46 | EVENT_TYPES = [ 47 | ('added', 'Added'), 48 | ('deleted', 'Deleted'), 49 | ('name_changed', 'Name Changed'), 50 | ] 51 | 52 | instance_type = models.CharField(max_length=10, choices=INSTANCE_TYPES) 53 | instance_id = models.IntegerField() 54 | event_type = models.CharField(max_length=20, choices=EVENT_TYPES) 55 | old_value = models.CharField(max_length=100, null=True, blank=True) 56 | new_value = models.CharField(max_length=100, null=True, blank=True) 57 | timestamp = models.DateTimeField(auto_now_add=True) 58 | processed = models.BooleanField(default=False) 59 | 60 | @receiver(pre_save, sender=RadarrInstance) 61 | def radarr_instance_about_to_save(sender, instance, **kwargs): 62 | if instance.pk: # Only for existing instances, not new ones 63 | try: 64 | # Get the current state from DB before save happens 65 | instance._old_instance = RadarrInstance.objects.get(pk=instance.pk) 66 | except RadarrInstance.DoesNotExist: 67 | pass 68 | 69 | # Signal handlers to track changes 70 | @receiver(post_save, sender=RadarrInstance) 71 | def radarr_instance_saved(sender, instance, created, **kwargs): 72 | print('radarr_instance_saved') 73 | if created: 74 | InstanceChangeLog.objects.create( 75 | instance_type='radarr', 76 | instance_id=instance.id, 77 | event_type='added', 78 | new_value=instance.name 79 | ) 80 | else: 81 | # Check for name change using the cached old instance 82 | if hasattr(instance, '_old_instance') and instance._old_instance.name != instance.name: 83 | InstanceChangeLog.objects.create( 84 | instance_type='radarr', 85 | instance_id=instance.id, 86 | event_type='name_changed', 87 | old_value=instance._old_instance.name, 88 | new_value=instance.name 89 | ) 90 | 91 | @receiver(pre_delete, sender=RadarrInstance) 92 | def radarr_instance_deleted(sender, instance, **kwargs): 93 | InstanceChangeLog.objects.create( 94 | instance_type='radarr', 95 | instance_id=instance.id, 96 | event_type='deleted', 97 | old_value=instance.name 98 | ) 99 | 100 | @receiver(pre_save, sender=SonarrInstance) 101 | def rsonarr_instance_about_to_save(sender, instance, **kwargs): 102 | if instance.pk: # Only for existing instances, not new ones 103 | try: 104 | # Get the current state from DB before save happens 105 | instance._old_instance = SonarrInstance.objects.get(pk=instance.pk) 106 | except SonarrInstance.DoesNotExist: 107 | pass 108 | 109 | # Sonarr signal handlers 110 | @receiver(post_save, sender=SonarrInstance) 111 | def sonarr_instance_saved(sender, instance, created, **kwargs): 112 | if created: 113 | InstanceChangeLog.objects.create( 114 | instance_type='sonarr', 115 | instance_id=instance.id, 116 | event_type='added', 117 | new_value=instance.name 118 | ) 119 | else: 120 | # Check for name change using the cached old instance 121 | if hasattr(instance, '_old_instance') and instance._old_instance.name != instance.name: 122 | InstanceChangeLog.objects.create( 123 | instance_type='sonarr', 124 | instance_id=instance.id, 125 | event_type='name_changed', 126 | old_value=instance._old_instance.name, 127 | new_value=instance.name 128 | ) 129 | 130 | @receiver(pre_delete, sender=SonarrInstance) 131 | def sonarr_instance_deleted(sender, instance, **kwargs): 132 | InstanceChangeLog.objects.create( 133 | instance_type='sonarr', 134 | instance_id=instance.id, 135 | event_type='deleted', 136 | old_value=instance.name 137 | ) 138 | 139 | class Log(models.Model): 140 | id = models.BigAutoField(primary_key=True) 141 | date = models.DateTimeField() 142 | status = models.IntegerField() 143 | provider = models.IntegerField() 144 | text = models.TextField() 145 | 146 | class Meta: 147 | verbose_name_plural = "log" 148 | 149 | def __str__(self): 150 | return self.text -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/arr.py: -------------------------------------------------------------------------------- 1 | import logging, time, json, re, traceback 2 | from urllib.parse import urlsplit 3 | from .connect import Connect 4 | import traceback 5 | from .models import RadarrInstance, SonarrInstance 6 | 7 | class SonarrAPI(): 8 | def __init__(self, url=None, apikey=None, instance_id=None): 9 | self.connect = Connect() 10 | self.name = None 11 | 12 | # If instance_id is provided, fetch the instance details 13 | if instance_id is not None: 14 | try: 15 | instance = SonarrInstance.objects.get(id=instance_id) 16 | self.url = self._get_url(instance.url) 17 | self.apikey = instance.apikey 18 | self.name = instance.name 19 | except SonarrInstance.DoesNotExist: 20 | raise ValueError(f"Sonarr instance with ID {instance_id} not found") 21 | # Otherwise use the provided URL and API key 22 | elif url and apikey: 23 | self.url = self._get_url(url) 24 | self.apikey = apikey 25 | else: 26 | # Fallback to default instance if no parameters provided 27 | try: 28 | instance = SonarrInstance.objects.first() 29 | if instance: 30 | self.url = self._get_url(instance.url) 31 | self.apikey = instance.apikey 32 | self.name = instance.name 33 | else: 34 | raise ValueError("No Sonarr instance found and no URL/API key provided") 35 | except Exception as e: 36 | raise ValueError(f"Failed to initialize SonarrAPI: {str(e)}") 37 | 38 | def _get_url(self, url): 39 | if not re.match(r'http(s?)\:', url): 40 | url = 'http://' + url 41 | parsed = urlsplit(url) 42 | return f"{parsed.scheme}://{parsed.netloc}" 43 | 44 | def get_status(self): 45 | try: 46 | json = self.connect.get_json(f"{self.url}/api/v3/system/status", params={"apikey": self.apikey}) 47 | return {'status': 1, 'message': 'Ok', 'json': json} 48 | except Exception as e: 49 | return {'status': 0, 'message': f'Error connecting to Sonarr API: {str(e)}'} 50 | 51 | def get_quality_profile(self): 52 | try: 53 | json = self.connect.get_json(f"{self.url}/api/v3/qualityprofile", params={"apikey": self.apikey}) 54 | return json 55 | except Exception as e: 56 | return [{'id': 0, 'name': f'Error connecting to Sonarr API: {str(e)}'}] 57 | 58 | def get_root_folder(self): 59 | try: 60 | json = self.connect.get_json(f"{self.url}/api/v3/rootfolder", params={"apikey": self.apikey}) 61 | return json 62 | except Exception as e: 63 | return [{'id': 0, 'path': f'Error connecting to Sonarr API: {str(e)}'}] 64 | 65 | def get_series(self): 66 | try: 67 | json = self.connect.get_json(f"{self.url}/api/v3/series", params={"apikey": self.apikey}) 68 | return json 69 | except Exception as e: 70 | return [{'result': f'Error connecting to Sonarr API: {str(e)}'}] 71 | 72 | def post_show(self, payload): 73 | try: 74 | return self.connect.post_json(f"{self.url}/api/v3/series", json=payload, params={"apikey": self.apikey}) 75 | except Exception: 76 | return {'errorMessage': traceback.format_exc()} 77 | 78 | class RadarrAPI(): 79 | def __init__(self, url=None, apikey=None, instance_id=None): 80 | self.connect = Connect() 81 | self.name = None 82 | 83 | # If instance_id is provided, fetch the instance details 84 | if instance_id is not None: 85 | try: 86 | instance = RadarrInstance.objects.get(id=instance_id) 87 | self.url = self._get_url(instance.url) 88 | self.apikey = instance.apikey 89 | self.name = instance.name 90 | except RadarrInstance.DoesNotExist: 91 | raise ValueError(f"Radarr instance with ID {instance_id} not found") 92 | # Otherwise use the provided URL and API key 93 | elif url and apikey: 94 | self.url = self._get_url(url) 95 | self.apikey = apikey 96 | else: 97 | # Fallback to default instance if no parameters provided 98 | try: 99 | instance = RadarrInstance.objects.first() 100 | if instance: 101 | self.url = self._get_url(instance.url) 102 | self.apikey = instance.apikey 103 | self.name = instance.name 104 | else: 105 | raise ValueError("No Radarr instance found and no URL/API key provided") 106 | except Exception as e: 107 | raise ValueError(f"Failed to initialize RadarrAPI: {str(e)}") 108 | 109 | def _get_url(self, url): 110 | if not re.match(r'http(s?)\:', url): 111 | url = 'http://' + url 112 | parsed = urlsplit(url) 113 | return f"{parsed.scheme}://{parsed.netloc}" 114 | 115 | def get_status(self): 116 | try: 117 | json = self.connect.get_json(f"{self.url}/api/v3/system/status", params={"apikey": self.apikey}) 118 | return {'status': 1, 'message': 'Ok', 'json': json} 119 | except Exception as e: 120 | return {'status': 0, 'message': f'Error connecting to Radarr API: {str(e)}'} 121 | 122 | def get_quality_profile(self): 123 | try: 124 | json = self.connect.get_json(f"{self.url}/api/v3/qualityprofile", params={"apikey": self.apikey}) 125 | return json 126 | except Exception as e: 127 | return [{'id': 0, 'name': f'Error connecting to Radarr API: {str(e)}'}] 128 | 129 | def get_root_folder(self): 130 | try: 131 | json = self.connect.get_json(f"{self.url}/api/v3/rootfolder", params={"apikey": self.apikey}) 132 | return json 133 | except Exception as e: 134 | return [{'id': 0, 'path': f'Error connecting to Radarr API: {str(e)}'}] 135 | 136 | def get_movies(self): 137 | try: 138 | json = self.connect.get_json(f"{self.url}/api/v3/movie", params={"apikey": self.apikey}) 139 | return json 140 | except Exception as e: 141 | return [{'result': f'Error connecting to Radarr API: {str(e)}'}] 142 | 143 | def post_movie(self, payload): 144 | try: 145 | return self.connect.post_json(f"{self.url}/api/v3/movie", json=payload, params={"apikey": self.apikey}) 146 | except Exception: 147 | return {'errorMessage': traceback.format_exc()} 148 | 149 | class MdblistAPI(): 150 | def __init__(self, apikey): 151 | self.connect = Connect() 152 | self.url = "https://mdblist.com" 153 | self.apikey = apikey 154 | 155 | def test_api(self, apikey): 156 | try: 157 | json = self.connect.get_json(f"{self.url}/api", params={"apikey": apikey if apikey else self.apikey, 'i': 'tt0073195'}) 158 | if json.get('title'): 159 | return True 160 | else: 161 | return False 162 | except: 163 | return False 164 | 165 | def post_arr_payload(self, payload): 166 | try: 167 | return(self.connect.post_json(f"{self.url}/service/mdblist/arr", json=payload, params={"apikey": self.apikey})) 168 | except: 169 | return {'response': f'{traceback.format_exc()}'} 170 | 171 | def get_mdblist_queue(self): 172 | try: 173 | return(self.connect.get_json(f"{self.url}/service/mdblist/queue", params={"apikey": self.apikey})) 174 | except: 175 | return {'response': f'{traceback.format_exc()}'} 176 | 177 | def post_arr_changes(self, payload): 178 | try: 179 | return(self.connect.post_json(f"{self.url}/service/mdblist/config", json=payload, params={"apikey": self.apikey})) 180 | except: 181 | return {'response': 'Exception', 'error': f'{traceback.format_exc()}'} -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/cron.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.utils import timezone 3 | from .connect import Connect 4 | import time 5 | import json 6 | import random 7 | import traceback 8 | from .models import Log, InstanceChangeLog, RadarrInstance, SonarrInstance 9 | from .views import MDBListarr 10 | from .arr import SonarrAPI 11 | from .arr import RadarrAPI 12 | 13 | def save_log(provider, status, text): 14 | log = Log() 15 | log.date = timezone.now() 16 | log.provider = provider 17 | log.status = status 18 | log.text = text 19 | log.save() 20 | 21 | def post_radarr_payload(): 22 | try: 23 | time.sleep(random.uniform(0.0, 3600.0)) 24 | mdblistarr = MDBListarr() 25 | radarr_api = RadarrAPI() 26 | movies = radarr_api.get_movies() 27 | 28 | provider = 1 # Radarr JSON POST 29 | 30 | json = [] 31 | for movie in movies: 32 | if movie.get('tmdbId'): 33 | exists = None 34 | if movie['hasFile']: 35 | exists = True 36 | elif movie['monitored']: 37 | exists = False 38 | if exists is not None: 39 | json.append({'exists':exists, 'tmdb':movie.get('tmdbId')}) 40 | total_records = len(json) 41 | json_payload = {'radarr': json} 42 | 43 | res = mdblistarr.mdblist.post_arr_payload(json_payload) 44 | 45 | if res.get('response') == 'Ok': 46 | save_log(provider, 1, f'{radarr_api.name}: Uploaded {total_records} records to MDBList.com') 47 | return JsonResponse(res) 48 | else: 49 | save_log(provider, 2, f'Upload records to MDBList.com Failed: {res}') 50 | return JsonResponse(res) 51 | except: 52 | save_log(provider, 2, f'{traceback.format_exc()}') 53 | return JsonResponse({'response': 'Exception'}) 54 | 55 | def post_sonarr_payload(): 56 | try: 57 | time.sleep(random.uniform(0.0, 3600.0)) 58 | mdblistarr = MDBListarr() 59 | sonarr_api = SonarrAPI() 60 | series = sonarr_api.get_series() 61 | 62 | provider = 2 # Sonarr JSON POST 63 | 64 | json = [] 65 | for show in series: 66 | if show.get('tvdbId'): 67 | exists = None 68 | if show.get('seasons'): 69 | for season in show['seasons']: 70 | if season.get('statistics', {}).get('percentOfEpisodes') == 100: 71 | exists = True # At least one season is downloaded 100% 72 | elif show['monitored']: 73 | exists = False 74 | if exists is not None: 75 | json.append({'exists':exists, 'tvdb':show.get('tvdbId')}) 76 | total_records = len(json) 77 | json_payload = {'sonarr': json} 78 | 79 | res = mdblistarr.mdblist.post_arr_payload(json_payload) 80 | if res.get('response') == 'Ok': 81 | save_log(provider, 1, f'{sonarr_api.name}: Uploaded {total_records} records to MDBList.com') 82 | return JsonResponse(res) 83 | else: 84 | save_log(provider, 2, f'Upload records to MDBList.com Failed: {res}') 85 | return JsonResponse(res) 86 | except: 87 | save_log(provider, 2, f'{traceback.format_exc()}') 88 | return JsonResponse({'response': 'Exception'}) 89 | 90 | def get_mdblist_queue_to_arr(): 91 | 92 | try: 93 | time.sleep(random.uniform(0.0, 36.0)) 94 | mdblistarr = MDBListarr() 95 | queue = mdblistarr.mdblist.get_mdblist_queue() 96 | 97 | for item in queue: 98 | if item['mediatype'] == 'movie': 99 | provider = 1 100 | instance_id = item.get('instanceid') 101 | movie_request_json = { 102 | "title": item['title'], 103 | "tmdbid": item['tmdbid'], 104 | "monitored": True, 105 | "addOptions": {"searchForMovie": True}, 106 | "qualityProfileId": mdblistarr.get_radarr_quality_profile(instance_id), 107 | "rootFolderPath": mdblistarr.get_radarr_root_folder(instance_id) 108 | } 109 | 110 | radarr_api = RadarrAPI(instance_id=instance_id) 111 | res = radarr_api.post_movie(movie_request_json) 112 | if isinstance(res, list): 113 | if res[0].get('errorMessage'): 114 | save_log(provider, 2, f"Error adding movie to Radarr: {item['title']}. {res[0]['errorMessage']}.") 115 | else: 116 | save_log(provider, 2, f"Error posting movie to Radarr: {item['title']}. Raw response: {res}") 117 | print(f"Error posting movie to Radarr: {item['title']}. Raw response: {res}") # Print to console 118 | # save_log(provider, 2, f"Error posting movie to Radarr") 119 | elif res.get('title'): 120 | save_log(provider, 1, f"Added movie to Radarr: {item['title']}.") 121 | elif res.get('errorMessage'): 122 | save_log(provider, 2, f"Error posting movie to Radarr: {item['title']}. {res['errorMessage']}") 123 | else: 124 | # Log the full response for debugging 125 | save_log(provider, 2, f"Error posting movie to Radarr: {item['title']}. Raw response: {res}") 126 | print(f"Error posting movie to Radarr: {item['title']}. Raw response: {res}") # Print to console 127 | # save_log(provider, 2, f"Error posting movie to Radarr") 128 | elif item['mediatype'] == 'show': 129 | provider = 2 130 | instance_id = item.get('instanceid') 131 | show_request_json = { 132 | "title": item['title'], 133 | "tvdbid": item['tvdbid'], 134 | "monitored": True, 135 | "addOptions": {"searchForMissingEpisodes": True}, 136 | "qualityProfileId": mdblistarr.get_sonarr_quality_profile(instance_id), 137 | "rootFolderPath": mdblistarr.get_sonarr_root_folder(instance_id) 138 | } 139 | 140 | sonarr_api = SonarrAPI(instance_id=instance_id) 141 | res = sonarr_api.post_show(show_request_json) 142 | if isinstance(res, list): 143 | if res[0].get('errorMessage'): 144 | save_log(provider, 2, f"Error adding show to Sonarr: {item['title']}. {res[0]['errorMessage']}") 145 | else: 146 | save_log(provider, 2, f"Error posting show to Sonarr: {item['title']}. Raw response: {res}") 147 | print(f"Error posting show to Sonarr: {item['title']}. Raw response: {res}") # Print to console 148 | # save_log(provider, 2, f"Error posting show to Sonarr") 149 | elif res.get('title'): 150 | save_log(provider, 1, f"Added show to Sonarr {item['title']}.") 151 | elif res.get('errorMessage'): 152 | save_log(provider, 2, f"Error posting show to Sonarr: {item['title']}. {res['errorMessage']}") 153 | else: 154 | # Log the full response for debugging 155 | save_log(provider, 2, f"Error posting show to Sonarr: {item['title']}. Raw response: {res}") 156 | print(f"Error posting show to Sonarr: {item['title']}. Raw response: {res}") # Print to console 157 | # save_log(provider, 2, f"Error posting show to Sonarr") 158 | except: 159 | save_log(provider, 2, f'{traceback.format_exc()}') 160 | return JsonResponse({'result': 500}) 161 | 162 | return JsonResponse({'result': 200}) 163 | 164 | def process_instance_changes(): 165 | try: 166 | provider = 3 # Instance Change Log 167 | logs = InstanceChangeLog.objects.filter(processed=False).order_by('timestamp') 168 | 169 | if not logs.exists(): 170 | return JsonResponse({"response": "Log is empty"}) 171 | 172 | time.sleep(random.uniform(0.0, 36.0)) 173 | 174 | radarr_instances = RadarrInstance.objects.all() 175 | sonarr_instances = SonarrInstance.objects.all() 176 | 177 | json_payload = { 178 | "instances": { 179 | "radarr": [ 180 | { 181 | "instance_id": radarr.id, 182 | "instance_name": radarr.name, 183 | } for radarr in radarr_instances 184 | ], 185 | "sonarr": [ 186 | { 187 | "instance_id": sonarr.id, 188 | "instance_name": sonarr.name, 189 | } for sonarr in sonarr_instances 190 | ] 191 | } 192 | } 193 | 194 | if not radarr_instances and not sonarr_instances: 195 | logs.update(processed=True) 196 | return JsonResponse({"response": "No instances to sync"}) 197 | 198 | mdblistarr = MDBListarr() 199 | res = mdblistarr.mdblist.post_arr_changes(json_payload) 200 | 201 | if res.get('response') == 'Ok': 202 | logs.update(processed=True) 203 | save_log(provider, 1, f'Configuration uploaded to MDBList.com') 204 | return JsonResponse(res) 205 | else: 206 | save_log(provider, 2, f'Configuration upload to MDBList.com Failed: {res}') 207 | return JsonResponse(res) 208 | except Exception as e: 209 | save_log(provider, 2, f'{traceback.format_exc()}') 210 | return JsonResponse({'result': 500}) 211 | 212 | -------------------------------------------------------------------------------- /mdblistarr/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 |
5 |
6 | 7 | 8 | {% if messages %} 9 | {% for message in messages %} 10 | 13 | {% endfor %} 14 | {% endif %} 15 | 16 | 17 |
18 | {% csrf_token %} 19 | 20 |
21 | {% if mdblist_form.non_field_errors %} 22 | 25 | {% endif %} 26 |

27 |

28 | MDBList 29 | Configuration Options 30 |

31 |
32 | {{ mdblist_form.mdblist_apikey.label_tag }} 33 | {{ mdblist_form.mdblist_apikey }} 34 |
35 | {{ mdblist_form.mdblist_apikey.errors.0 }} 36 |
37 |
38 | {% if not mdblist_form.errors %}API key validated successfully{% else %}{{ mdblist_form.mdblist_apikey.help_text }}{% endif %} 39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 | 47 | 48 |

49 | Radarr 50 | Configuration Options 51 |

52 | 53 | 54 |
55 | {% csrf_token %} 56 | 57 |
58 |
59 | {{ radarr_selection_form.server_selection.label_tag }} 60 | {{ radarr_selection_form.server_selection }} 61 |
62 |
63 |
64 | 65 |
66 |
67 | 68 | 69 | {% if radarr_form %} 70 |
71 |
72 |
Radarr Server Configuration
73 |
74 |
75 |
76 | {% csrf_token %} 77 | 78 | 79 | 80 | {% if radarr_form.non_field_errors %} 81 | 84 | {% endif %} 85 | 86 | 87 |
88 | {{ radarr_form.name.label_tag }} 89 | {{ radarr_form.name }} 90 |
91 | {{ radarr_form.name.errors.0 }} 92 |
93 |
94 | 95 | 96 |
97 | {{ radarr_form.url.label_tag }} 98 | {{ radarr_form.url }} 99 |
100 | {{ radarr_form.url.errors.0 }} 101 |
102 |
103 | 104 | 105 |
106 | {{ radarr_form.apikey.label_tag }} 107 | {{ radarr_form.apikey }} 108 |
109 | {{ radarr_form.apikey.errors.0 }} 110 |
111 |
112 | {{ radarr_form.apikey.help_text }} 113 |
114 |
115 | 116 | 117 |
118 | {{ radarr_form.quality_profile.label_tag }} 119 | 126 |
127 | {{ radarr_form.quality_profile.errors.0 }} 128 |
129 |
130 | 131 | 132 |
133 | {{ radarr_form.root_folder.label_tag }} 134 | 141 |
142 | {{ radarr_form.root_folder.errors.0 }} 143 |
144 |
145 | 146 |
147 | 148 | 149 | {% if active_radarr_id %} 150 | 151 | {% endif %} 152 |
153 |
154 |
155 |
156 | {% endif %} 157 | 158 | 159 |

160 | Sonarr 161 | Configuration Options 162 |

163 | 164 | 165 |
166 | {% csrf_token %} 167 | 168 |
169 |
170 | {{ sonarr_selection_form.server_selection.label_tag }} 171 | {{ sonarr_selection_form.server_selection }} 172 |
173 |
174 |
175 | 176 |
177 |
178 | 179 | 180 | {% if sonarr_form %} 181 |
182 |
183 |
Sonarr Server Configuration
184 |
185 |
186 |
187 | {% csrf_token %} 188 | 189 | 190 | 191 | {% if sonarr_form.non_field_errors %} 192 | 195 | {% endif %} 196 | 197 | 198 |
199 | {{ sonarr_form.name.label_tag }} 200 | {{ sonarr_form.name }} 201 |
202 | {{ sonarr_form.name.errors.0 }} 203 |
204 |
205 | 206 | 207 |
208 | {{ sonarr_form.url.label_tag }} 209 | {{ sonarr_form.url }} 210 |
211 | {{ sonarr_form.url.errors.0 }} 212 |
213 |
214 | 215 | 216 |
217 | {{ sonarr_form.apikey.label_tag }} 218 | {{ sonarr_form.apikey }} 219 |
220 | {{ sonarr_form.apikey.errors.0 }} 221 |
222 |
223 | {{ sonarr_form.apikey.help_text }} 224 |
225 |
226 | 227 | 228 |
229 | {{ sonarr_form.quality_profile.label_tag }} 230 | 237 |
238 | {{ sonarr_form.quality_profile.errors.0 }} 239 |
240 |
241 | 242 | 243 |
244 | {{ sonarr_form.root_folder.label_tag }} 245 | 252 |
253 | {{ sonarr_form.root_folder.errors.0 }} 254 |
255 |
256 | 257 |
258 | 259 | 260 | {% if active_sonarr_id %} 261 | 262 | {% endif %} 263 |
264 |
265 |
266 |
267 | {% endif %} 268 |
269 | 270 | 359 | {% endblock %} -------------------------------------------------------------------------------- /mdblistarr/mdblistrr/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect, get_object_or_404 2 | from django.views.decorators.csrf import csrf_exempt 3 | from django.http import JsonResponse, HttpResponseRedirect 4 | from django import forms 5 | from django.urls import reverse 6 | from .models import Preferences, RadarrInstance, SonarrInstance 7 | from .connect import Connect 8 | from .arr import SonarrAPI 9 | from .arr import RadarrAPI 10 | from .arr import MdblistAPI 11 | import traceback 12 | import json 13 | import logging 14 | from django.contrib import messages 15 | # Set up logging 16 | logger = logging.getLogger(__name__) 17 | 18 | class MDBListarr(): 19 | def __init__(self): 20 | self.mdblist_apikey = None 21 | self.mdblist = None 22 | self._get_config() 23 | 24 | if self.mdblist_apikey: 25 | self.mdblist = MdblistAPI(self.mdblist_apikey) 26 | 27 | def _get_config(self): 28 | pref = Preferences.objects.filter(name='mdblist_apikey').first() 29 | if pref is not None: 30 | self.mdblist_apikey = pref.value 31 | 32 | def get_radarr_quality_profile_choices(self, url, apikey): 33 | choices_list = [('0', 'Select Quality Profile')] 34 | try: 35 | radarr = RadarrAPI(url, apikey) 36 | if radarr: 37 | quality_profiles = radarr.get_quality_profile() 38 | for profile in quality_profiles: 39 | choices_list.append((str(profile['id']), profile['name'])) 40 | except Exception as e: 41 | logger.error(f"Error fetching Radarr quality profiles: {str(e)}") 42 | return choices_list 43 | 44 | def get_radarr_root_folder_choices(self, url, apikey): 45 | choices_list = [('0', 'Select Root Folder')] 46 | try: 47 | radarr = RadarrAPI(url, apikey) 48 | if radarr: 49 | root_folders = radarr.get_root_folder() 50 | for folder in root_folders: 51 | choices_list.append((folder['path'], folder['path'])) 52 | except Exception as e: 53 | logger.error(f"Error fetching Radarr root folders: {str(e)}") 54 | return choices_list 55 | 56 | def get_sonarr_quality_profile_choices(self, url, apikey): 57 | choices_list = [('0', 'Select Quality Profile')] 58 | try: 59 | sonarr = SonarrAPI(url, apikey) 60 | if sonarr: 61 | quality_profiles = sonarr.get_quality_profile() 62 | for profile in quality_profiles: 63 | choices_list.append((str(profile['id']), profile['name'])) 64 | except Exception as e: 65 | logger.error(f"Error fetching Sonarr quality profiles: {str(e)}") 66 | return choices_list 67 | 68 | def get_sonarr_root_folder_choices(self, url, apikey): 69 | choices_list = [('0', 'Select Root Folder')] 70 | try: 71 | sonarr = SonarrAPI(url, apikey) 72 | if sonarr: 73 | root_folders = sonarr.get_root_folder() 74 | for folder in root_folders: 75 | choices_list.append((folder['path'], folder['path'])) 76 | except Exception as e: 77 | logger.error(f"Error fetching Sonarr root folders: {str(e)}") 78 | return choices_list 79 | 80 | def test_radarr_connection(self, url, apikey): 81 | try: 82 | radarr = RadarrAPI(url, apikey) 83 | status = radarr.get_status() 84 | if status['status'] == 1: 85 | return { 86 | 'status': True, 87 | 'version': f"{status['json']['instanceName']} {status['json']['version']}" 88 | } 89 | except Exception: 90 | pass 91 | return {'status': False, 'version': ''} 92 | 93 | def test_sonarr_connection(self, url, apikey): 94 | try: 95 | sonarr = SonarrAPI(url, apikey) 96 | status = sonarr.get_status() 97 | if status['status'] == 1: 98 | return { 99 | 'status': True, 100 | 'version': f"{status['json']['instanceName']} {status['json']['version']}" 101 | } 102 | except Exception: 103 | pass 104 | return {'status': False, 'version': ''} 105 | 106 | 107 | def get_radarr_quality_profile(self, instance_id=None): 108 | """ 109 | Get quality profile ID for a Radarr instance. 110 | Returns the quality profile ID of the specified instance or the first available instance if not found. 111 | """ 112 | try: 113 | if instance_id: 114 | instance = RadarrInstance.objects.filter(id=instance_id).first() 115 | if instance and instance.quality_profile: 116 | return instance.quality_profile 117 | 118 | first_instance = RadarrInstance.objects.filter(quality_profile__isnull=False).first() 119 | if first_instance: 120 | return first_instance.quality_profile 121 | 122 | return 0 123 | except Exception as e: 124 | logger.error(f"Error getting Radarr quality profile: {str(e)}") 125 | return 0 126 | 127 | def get_radarr_root_folder(self, instance_id=None): 128 | """ 129 | Get root folder path for a Radarr instance. 130 | Returns the root folder path of the specified instance or the first available instance if not found. 131 | """ 132 | try: 133 | if instance_id: 134 | instance = RadarrInstance.objects.filter(id=instance_id).first() 135 | if instance and instance.root_folder: 136 | return instance.root_folder 137 | 138 | first_instance = RadarrInstance.objects.filter(root_folder__isnull=False).first() 139 | if first_instance: 140 | return first_instance.root_folder 141 | 142 | return "" 143 | except Exception as e: 144 | logger.error(f"Error getting Radarr root folder: {str(e)}") 145 | return "" 146 | 147 | def get_sonarr_quality_profile(self, instance_id=None): 148 | """ 149 | Get quality profile ID for a Radarr instance. 150 | Returns the quality profile ID of the specified instance or the first available instance if not found. 151 | """ 152 | try: 153 | if instance_id: 154 | instance = SonarrInstance.objects.filter(id=instance_id).first() 155 | if instance and instance.quality_profile: 156 | return instance.quality_profile 157 | 158 | first_instance = SonarrInstance.objects.filter(quality_profile__isnull=False).first() 159 | if first_instance: 160 | return first_instance.quality_profile 161 | 162 | return 0 163 | except Exception as e: 164 | logger.error(f"Error getting Radarr quality profile: {str(e)}") 165 | return 0 166 | 167 | def get_sonarr_root_folder(self, instance_id=None): 168 | """ 169 | Get root folder path for a Radarr instance. 170 | Returns the root folder path of the specified instance or the first available instance if not found. 171 | """ 172 | try: 173 | if instance_id: 174 | instance = SonarrInstance.objects.filter(id=instance_id).first() 175 | if instance and instance.root_folder: 176 | return instance.root_folder 177 | 178 | first_instance = SonarrInstance.objects.filter(root_folder__isnull=False).first() 179 | if first_instance: 180 | return first_instance.root_folder 181 | 182 | return "" 183 | except Exception as e: 184 | logger.error(f"Error getting Radarr root folder: {str(e)}") 185 | return "" 186 | 187 | 188 | mdblistarr = MDBListarr() 189 | 190 | class MDBListForm(forms.Form): 191 | mdblist_apikey = forms.CharField( 192 | label='MDBList API Key', 193 | widget=forms.TextInput(attrs={'placeholder': 'Enter your mdblist.com API key', 'class': 'form-control'}) 194 | ) 195 | 196 | def clean(self): 197 | cleaned_data = super().clean() 198 | mdblist_apikey = cleaned_data.get('mdblist_apikey') 199 | 200 | if mdblist_apikey: 201 | if mdblistarr.mdblist is None: 202 | mdblistarr.mdblist = MdblistAPI(mdblist_apikey) 203 | if not mdblistarr.mdblist.test_api(mdblist_apikey): 204 | self._errors['mdblist_apikey'] = self.error_class(['API key is invalid, unable to connect']) 205 | self.fields['mdblist_apikey'].widget.attrs.update({'class': 'form-control is-invalid'}) 206 | # self.add_error(None, "API key is invalid. Unable to save changes.") 207 | 208 | else: 209 | self.fields['mdblist_apikey'].widget.attrs.update({'class': 'form-control is-valid'}) 210 | 211 | return cleaned_data 212 | 213 | class ServerSelectionForm(forms.Form): 214 | server_selection = forms.ChoiceField( 215 | label='Select Server', 216 | choices=[], 217 | widget=forms.Select(attrs={'class': 'form-select'}) 218 | ) 219 | 220 | def __init__(self, *args, choices=None, **kwargs): 221 | super(ServerSelectionForm, self).__init__(*args, **kwargs) 222 | if choices: 223 | self.fields['server_selection'].choices = choices 224 | 225 | class RadarrInstanceForm(forms.ModelForm): 226 | class Meta: 227 | model = RadarrInstance 228 | fields = ['name', 'url', 'apikey', 'quality_profile', 'root_folder'] 229 | widgets = { 230 | 'name': forms.TextInput(attrs={'placeholder': 'Instance Name', 'class': 'form-control'}), 231 | 'url': forms.TextInput(attrs={'placeholder': 'Radarr URL', 'class': 'form-control'}), 232 | 'apikey': forms.TextInput(attrs={'placeholder': 'Radarr API Key', 'class': 'form-control'}), 233 | 'quality_profile': forms.Select(attrs={'class': 'form-control'}), 234 | 'root_folder': forms.Select(attrs={'class': 'form-control'}), 235 | } 236 | 237 | def __init__(self, *args, **kwargs): 238 | super(RadarrInstanceForm, self).__init__(*args, **kwargs) 239 | 240 | self.fields['quality_profile'].choices = [('0', 'Select Quality Profile')] 241 | self.fields['root_folder'].choices = [('0', 'Select Root Folder')] 242 | 243 | if self.instance and self.instance.pk and self.instance.url and self.instance.apikey: 244 | try: 245 | quality_choices = mdblistarr.get_radarr_quality_profile_choices(self.instance.url, self.instance.apikey) 246 | root_choices = mdblistarr.get_radarr_root_folder_choices(self.instance.url, self.instance.apikey) 247 | 248 | self.fields['quality_profile'].choices = quality_choices 249 | self.fields['root_folder'].choices = root_choices 250 | 251 | if self.instance.quality_profile and not any(str(self.instance.quality_profile) == choice[0] for choice in quality_choices): 252 | self.fields['quality_profile'].choices.append((self.instance.quality_profile, f"Profile {self.instance.quality_profile} (saved)")) 253 | 254 | if self.instance.root_folder and not any(self.instance.root_folder == choice[0] for choice in root_choices): 255 | self.fields['root_folder'].choices.append((self.instance.root_folder, self.instance.root_folder)) 256 | except Exception as e: 257 | logger.error(f"Error initializing RadarrInstanceForm: {str(e)}") 258 | 259 | class SonarrInstanceForm(forms.ModelForm): 260 | class Meta: 261 | model = SonarrInstance 262 | fields = ['name', 'url', 'apikey', 'quality_profile', 'root_folder'] 263 | widgets = { 264 | 'name': forms.TextInput(attrs={'placeholder': 'Instance Name', 'class': 'form-control'}), 265 | 'url': forms.TextInput(attrs={'placeholder': 'Sonarr URL', 'class': 'form-control'}), 266 | 'apikey': forms.TextInput(attrs={'placeholder': 'Sonarr API Key', 'class': 'form-control'}), 267 | 'quality_profile': forms.Select(attrs={'class': 'form-control'}), 268 | 'root_folder': forms.Select(attrs={'class': 'form-control'}), 269 | } 270 | 271 | def __init__(self, *args, **kwargs): 272 | super(SonarrInstanceForm, self).__init__(*args, **kwargs) 273 | 274 | self.fields['quality_profile'].choices = [('0', 'Select Quality Profile')] 275 | self.fields['root_folder'].choices = [('0', 'Select Root Folder')] 276 | 277 | if self.instance and self.instance.pk and self.instance.url and self.instance.apikey: 278 | try: 279 | quality_choices = mdblistarr.get_sonarr_quality_profile_choices(self.instance.url, self.instance.apikey) 280 | root_choices = mdblistarr.get_sonarr_root_folder_choices(self.instance.url, self.instance.apikey) 281 | 282 | self.fields['quality_profile'].choices = quality_choices 283 | self.fields['root_folder'].choices = root_choices 284 | 285 | if self.instance.quality_profile and not any(str(self.instance.quality_profile) == choice[0] for choice in quality_choices): 286 | self.fields['quality_profile'].choices.append((self.instance.quality_profile, f"Profile {self.instance.quality_profile} (saved)")) 287 | 288 | if self.instance.root_folder and not any(self.instance.root_folder == choice[0] for choice in root_choices): 289 | self.fields['root_folder'].choices.append((self.instance.root_folder, self.instance.root_folder)) 290 | except Exception as e: 291 | logger.error(f"Error initializing SonarrInstanceForm: {str(e)}") 292 | 293 | def home_view(request): 294 | mdblist_form = MDBListForm(initial={'mdblist_apikey': mdblistarr.mdblist_apikey}) 295 | 296 | radarr_instances = RadarrInstance.objects.all() 297 | sonarr_instances = SonarrInstance.objects.all() 298 | 299 | radarr_choices = [('new', '--- Add New Radarr Server ---')] 300 | radarr_choices.extend([(str(instance.id), instance.name) for instance in radarr_instances]) 301 | 302 | sonarr_choices = [('new', '--- Add New Sonarr Server ---')] 303 | sonarr_choices.extend([(str(instance.id), instance.name) for instance in sonarr_instances]) 304 | 305 | radarr_selection_form = ServerSelectionForm(choices=radarr_choices, prefix='radarr_select') 306 | sonarr_selection_form = ServerSelectionForm(choices=sonarr_choices, prefix='sonarr_select') 307 | 308 | radarr_form = RadarrInstanceForm() 309 | sonarr_form = SonarrInstanceForm() 310 | 311 | active_radarr_id = None 312 | active_sonarr_id = None 313 | 314 | if request.method == "POST": 315 | form_type = request.POST.get('form_type', '') 316 | 317 | if form_type == 'mdblist': 318 | mdblist_form = MDBListForm(request.POST) 319 | if mdblist_form.is_valid(): 320 | Preferences.objects.update_or_create( 321 | name='mdblist_apikey', 322 | defaults={'value': mdblist_form.cleaned_data['mdblist_apikey']} 323 | ) 324 | mdblistarr.mdblist_apikey = mdblist_form.cleaned_data['mdblist_apikey'] 325 | messages.success(request, "MDBList configuration saved successfully!") 326 | return HttpResponseRedirect(reverse('home_view')) 327 | 328 | elif form_type == 'radarr_select': 329 | radarr_selection_form = ServerSelectionForm(request.POST, choices=radarr_choices, prefix='radarr_select') 330 | if radarr_selection_form.is_valid(): 331 | server_id = radarr_selection_form.cleaned_data['server_selection'] 332 | if server_id != 'new': 333 | active_radarr_id = server_id 334 | instance = RadarrInstance.objects.get(id=server_id) 335 | radarr_form = RadarrInstanceForm(instance=instance) 336 | else: 337 | radarr_form = RadarrInstanceForm() 338 | 339 | elif form_type == 'sonarr_select': 340 | sonarr_selection_form = ServerSelectionForm(request.POST, choices=sonarr_choices, prefix='sonarr_select') 341 | if sonarr_selection_form.is_valid(): 342 | server_id = sonarr_selection_form.cleaned_data['server_selection'] 343 | if server_id != 'new': 344 | active_sonarr_id = server_id 345 | instance = SonarrInstance.objects.get(id=server_id) 346 | sonarr_form = SonarrInstanceForm(instance=instance) 347 | else: 348 | sonarr_form = SonarrInstanceForm() 349 | 350 | elif form_type == 'radarr_save': 351 | instance_id = request.POST.get('instance_id') 352 | 353 | if instance_id and instance_id != 'new': 354 | instance = get_object_or_404(RadarrInstance, id=instance_id) 355 | radarr_form = RadarrInstanceForm(request.POST, instance=instance) 356 | active_radarr_id = instance_id 357 | else: 358 | radarr_form = RadarrInstanceForm(request.POST) 359 | 360 | if radarr_form.is_valid(): 361 | instance = radarr_form.save(commit=False) 362 | 363 | connection = mdblistarr.test_radarr_connection(instance.url, instance.apikey) 364 | 365 | if connection['status']: 366 | instance.save() 367 | messages.success(request, "Radarr configuration saved successfully!") 368 | return HttpResponseRedirect(reverse('home_view')) 369 | else: 370 | radarr_form.add_error('apikey', 'Unable to connect to Radarr') 371 | radarr_form.fields['apikey'].widget.attrs.update({'class': 'form-control is-invalid'}) 372 | 373 | elif form_type == 'sonarr_save': 374 | instance_id = request.POST.get('instance_id') 375 | 376 | if instance_id and instance_id != 'new': 377 | instance = get_object_or_404(SonarrInstance, id=instance_id) 378 | sonarr_form = SonarrInstanceForm(request.POST, instance=instance) 379 | active_sonarr_id = instance_id 380 | else: 381 | sonarr_form = SonarrInstanceForm(request.POST) 382 | 383 | if sonarr_form.is_valid(): 384 | instance = sonarr_form.save(commit=False) 385 | 386 | connection = mdblistarr.test_sonarr_connection(instance.url, instance.apikey) 387 | 388 | if connection['status']: 389 | instance.save() 390 | messages.success(request, "Sonarr configuration saved successfully!") 391 | return HttpResponseRedirect(reverse('home_view')) 392 | else: 393 | sonarr_form.add_error('apikey', 'Unable to connect to Sonarr') 394 | sonarr_form.fields['apikey'].widget.attrs.update({'class': 'form-control is-invalid'}) 395 | 396 | elif form_type == 'radarr_delete': 397 | instance_id = request.POST.get('instance_id') 398 | if instance_id: 399 | RadarrInstance.objects.filter(id=instance_id).delete() 400 | return HttpResponseRedirect(reverse('home_view')) 401 | 402 | elif form_type == 'sonarr_delete': 403 | instance_id = request.POST.get('instance_id') 404 | if instance_id: 405 | SonarrInstance.objects.filter(id=instance_id).delete() 406 | return HttpResponseRedirect(reverse('home_view')) 407 | 408 | 409 | if active_radarr_id: 410 | radarr_selection_form.initial = {'server_selection': active_radarr_id} 411 | if active_sonarr_id: 412 | sonarr_selection_form.initial = {'server_selection': active_sonarr_id} 413 | 414 | context = { 415 | 'mdblist_form': mdblist_form, 416 | 'radarr_selection_form': radarr_selection_form, 417 | 'sonarr_selection_form': sonarr_selection_form, 418 | 'radarr_form': radarr_form, 419 | 'sonarr_form': sonarr_form, 420 | 'active_radarr_id': active_radarr_id, 421 | 'active_sonarr_id': active_sonarr_id, 422 | } 423 | 424 | return render(request, "index.html", context) 425 | 426 | @csrf_exempt 427 | def test_radarr_connection(request): 428 | if request.method == 'POST': 429 | data = json.loads(request.body) 430 | url = data.get('url') 431 | apikey = data.get('apikey') 432 | 433 | result = mdblistarr.test_radarr_connection(url, apikey) 434 | 435 | if result['status']: 436 | quality_profiles = mdblistarr.get_radarr_quality_profile_choices(url, apikey) 437 | root_folders = mdblistarr.get_radarr_root_folder_choices(url, apikey) 438 | 439 | return JsonResponse({ 440 | 'status': 'success', 441 | 'version': result['version'], 442 | 'quality_profiles': quality_profiles, 443 | 'root_folders': root_folders 444 | }) 445 | else: 446 | return JsonResponse({ 447 | 'status': 'error', 448 | 'message': 'Unable to connect to Radarr' 449 | }) 450 | 451 | return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) 452 | 453 | @csrf_exempt 454 | def test_sonarr_connection(request): 455 | if request.method == 'POST': 456 | data = json.loads(request.body) 457 | url = data.get('url') 458 | apikey = data.get('apikey') 459 | 460 | result = mdblistarr.test_sonarr_connection(url, apikey) 461 | 462 | if result['status']: 463 | quality_profiles = mdblistarr.get_sonarr_quality_profile_choices(url, apikey) 464 | root_folders = mdblistarr.get_sonarr_root_folder_choices(url, apikey) 465 | 466 | return JsonResponse({ 467 | 'status': 'success', 468 | 'version': result['version'], 469 | 'quality_profiles': quality_profiles, 470 | 'root_folders': root_folders 471 | }) 472 | else: 473 | return JsonResponse({ 474 | 'status': 'error', 475 | 'message': 'Unable to connect to Sonarr' 476 | }) 477 | 478 | return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) --------------------------------------------------------------------------------