├── 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 | 
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 | | Type |
12 | Status |
13 | Date |
14 | Log |
15 |
16 |
17 |
18 |
19 | {% for log in logs %}
20 |
21 | | {% 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 %} |
23 | {{ log.date|date:"Y-m-d H:i:s" }}
24 | | {{log.text}} |
25 |
26 | {% endfor %}
27 |
28 |
29 |
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 |
29 |
MDBListarr
30 |
Companion app for mdblist.com for Radarr and Sonarr integration.
31 |
32 |
Version: {% version_date %}
33 |
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 |
11 | {{ message }}
12 |
13 | {% endfor %}
14 | {% endif %}
15 |
16 |
17 |
46 |
47 |
48 |
49 | Radarr
50 | Configuration Options
51 |
52 |
53 |
54 |
67 |
68 |
69 | {% if radarr_form %}
70 |
156 | {% endif %}
157 |
158 |
159 |
160 | Sonarr
161 | Configuration Options
162 |
163 |
164 |
165 |
178 |
179 |
180 | {% if sonarr_form %}
181 |
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'})
--------------------------------------------------------------------------------