diff --git a/.gitignore b/.gitignore
index 36b13f1..6354ab9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,176 +1,8 @@
-# ---> Python
-# Byte-compiled / optimized / DLL files
__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# UV
-# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-#uv.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
+*.pyc
.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
+db.sqlite3
.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
+.pytest_cache/
+.coverage
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..c3b2ee7
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,16 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+django = "*"
+django-environ = "*"
+djangorestframework = "*"
+requests = "*"
+python-dateutil = "*"
+
+[dev-packages]
+
+[requires]
+python_version = "3.13"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..f83d154
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,212 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.13"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "asgiref": {
+ "hashes": [
+ "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142",
+ "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==3.9.1"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407",
+ "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==2025.8.3"
+ },
+ "charset-normalizer": {
+ "hashes": [
+ "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4",
+ "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45",
+ "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7",
+ "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0",
+ "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7",
+ "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d",
+ "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d",
+ "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0",
+ "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184",
+ "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db",
+ "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b",
+ "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64",
+ "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b",
+ "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8",
+ "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff",
+ "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344",
+ "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58",
+ "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e",
+ "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471",
+ "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148",
+ "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a",
+ "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836",
+ "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e",
+ "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63",
+ "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c",
+ "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1",
+ "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01",
+ "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366",
+ "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58",
+ "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5",
+ "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c",
+ "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2",
+ "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a",
+ "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597",
+ "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b",
+ "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5",
+ "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb",
+ "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f",
+ "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0",
+ "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941",
+ "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0",
+ "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86",
+ "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7",
+ "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7",
+ "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455",
+ "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6",
+ "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4",
+ "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0",
+ "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3",
+ "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1",
+ "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6",
+ "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981",
+ "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c",
+ "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980",
+ "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645",
+ "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7",
+ "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12",
+ "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa",
+ "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd",
+ "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef",
+ "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f",
+ "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2",
+ "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d",
+ "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5",
+ "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02",
+ "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3",
+ "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd",
+ "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e",
+ "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214",
+ "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd",
+ "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a",
+ "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c",
+ "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681",
+ "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba",
+ "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f",
+ "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a",
+ "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28",
+ "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691",
+ "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82",
+ "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a",
+ "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027",
+ "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7",
+ "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518",
+ "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf",
+ "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b",
+ "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9",
+ "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544",
+ "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da",
+ "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509",
+ "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f",
+ "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a",
+ "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==3.4.2"
+ },
+ "django": {
+ "hashes": [
+ "sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae",
+ "sha256:2b2ada0ee8a5ff743a40e2b9820d1f8e24c11bac9ae6469cd548f0057ea6ddcd"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.10'",
+ "version": "==5.2.5"
+ },
+ "django-environ": {
+ "hashes": [
+ "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a",
+ "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.9' and python_version < '4'",
+ "version": "==0.12.0"
+ },
+ "djangorestframework": {
+ "hashes": [
+ "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7",
+ "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.9'",
+ "version": "==3.16.1"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
+ "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==3.10"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
+ "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
+ "version": "==2.9.0.post0"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c",
+ "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==2.32.4"
+ },
+ "six": {
+ "hashes": [
+ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
+ "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
+ "version": "==1.17.0"
+ },
+ "sqlparse": {
+ "hashes": [
+ "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272",
+ "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==0.5.3"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760",
+ "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==2.5.0"
+ }
+ },
+ "develop": {}
+}
diff --git a/arr_api/__init__.py b/arr_api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/arr_api/admin.py b/arr_api/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/arr_api/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/arr_api/apps.py b/arr_api/apps.py
new file mode 100644
index 0000000..db37491
--- /dev/null
+++ b/arr_api/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ArrApiConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'arr_api'
diff --git a/arr_api/migrations/__init__.py b/arr_api/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/arr_api/models.py b/arr_api/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/arr_api/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/arr_api/services.py b/arr_api/services.py
new file mode 100644
index 0000000..c3dd113
--- /dev/null
+++ b/arr_api/services.py
@@ -0,0 +1,133 @@
+# arr_api/services.py
+import os
+import requests
+from datetime import datetime, timedelta, timezone
+from dateutil.parser import isoparse
+
+# ENV-Fallbacks
+ENV_SONARR_URL = os.getenv("SONARR_URL", "")
+ENV_SONARR_KEY = os.getenv("SONARR_API_KEY", "")
+ENV_RADARR_URL = os.getenv("RADARR_URL", "")
+ENV_RADARR_KEY = os.getenv("RADARR_API_KEY", "")
+DEFAULT_DAYS = int(os.getenv("ARR_DEFAULT_DAYS", "30"))
+
+class ArrServiceError(Exception):
+ pass
+
+def _get(url, headers, params=None, timeout=5):
+ try:
+ r = requests.get(url, headers=headers, params=params or {}, timeout=timeout)
+ r.raise_for_status()
+ return r.json()
+ except requests.exceptions.RequestException as e:
+ raise ArrServiceError(str(e))
+
+def _abs_url(base: str, p: str | None) -> str | None:
+ if not p:
+ return None
+ return f"{base.rstrip('/')}{p}" if p.startswith("/") else p
+
+def sonarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None):
+ base = (base_url or ENV_SONARR_URL).strip()
+ key = (api_key or ENV_SONARR_KEY).strip()
+ if not base or not key:
+ return []
+ d = days or DEFAULT_DAYS
+ start = datetime.now(timezone.utc)
+ end = start + timedelta(days=d)
+
+ url = f"{base.rstrip('/')}/api/v3/calendar"
+ headers = {"X-Api-Key": key}
+ data = _get(url, headers, params={
+ "start": start.date().isoformat(),
+ "end": end.date().isoformat(),
+ "unmonitored": "false",
+ "includeSeries": "true",
+ })
+
+ out = []
+ for ep in data:
+ series = ep.get("series") or {}
+ # Poster finden
+ poster = None
+ for img in (series.get("images") or []):
+ if (img.get("coverType") or "").lower() == "poster":
+ poster = img.get("remoteUrl") or _abs_url(base, img.get("url"))
+ if poster:
+ break
+
+ aired = isoparse(ep["airDateUtc"]).isoformat() if ep.get("airDateUtc") else None
+ out.append({
+ "seriesId": series.get("id"),
+ "seriesTitle": series.get("title"),
+ "seriesStatus": (series.get("status") or "").lower(),
+ "seriesPoster": poster,
+ "seriesOverview": series.get("overview") or "",
+ "seriesGenres": series.get("genres") or [],
+ "episodeId": ep.get("id"),
+ "seasonNumber": ep.get("seasonNumber"),
+ "episodeNumber": ep.get("episodeNumber"),
+ "title": ep.get("title"),
+ "airDateUtc": aired,
+ "tvdbId": series.get("tvdbId"),
+ "imdbId": series.get("imdbId"),
+ "network": series.get("network"),
+ })
+ return [x for x in out if x["seriesStatus"] == "continuing"]
+
+def radarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None):
+ base = (base_url or ENV_RADARR_URL).strip()
+ key = (api_key or ENV_RADARR_KEY).strip()
+ if not base or not key:
+ return []
+ d = days or DEFAULT_DAYS
+ start = datetime.now(timezone.utc)
+ end = start + timedelta(days=d)
+
+ url = f"{base.rstrip('/')}/api/v3/calendar"
+ headers = {"X-Api-Key": key}
+ data = _get(url, headers, params={
+ "start": start.date().isoformat(),
+ "end": end.date().isoformat(),
+ "unmonitored": "false",
+ "includeMovie": "true",
+ })
+
+ out = []
+ for it in data:
+ movie = it.get("movie") or it
+ # Poster finden
+ poster = None
+ for img in (movie.get("images") or []):
+ if (img.get("coverType") or "").lower() == "poster":
+ poster = img.get("remoteUrl") or _abs_url(base, img.get("url"))
+ if poster:
+ break
+
+ out.append({
+ "movieId": movie.get("id"),
+ "title": movie.get("title"),
+ "year": movie.get("year"),
+ "tmdbId": movie.get("tmdbId"),
+ "imdbId": movie.get("imdbId"),
+ "posterUrl": poster,
+ "overview": movie.get("overview") or "",
+ "inCinemas": movie.get("inCinemas"),
+ "physicalRelease": movie.get("physicalRelease"),
+ "digitalRelease": movie.get("digitalRelease"),
+ "hasFile": movie.get("hasFile"),
+ "isAvailable": movie.get("isAvailable"),
+ })
+
+ def is_upcoming(m):
+ for k in ("inCinemas", "physicalRelease", "digitalRelease"):
+ v = m.get(k)
+ if v:
+ try:
+ if isoparse(v) > datetime.now(timezone.utc):
+ return True
+ except Exception:
+ pass
+ return False
+
+ return [m for m in out if is_upcoming(m)]
diff --git a/arr_api/templates/arr_api/index.html b/arr_api/templates/arr_api/index.html
new file mode 100644
index 0000000..e223d3e
--- /dev/null
+++ b/arr_api/templates/arr_api/index.html
@@ -0,0 +1,738 @@
+
+
+
+
+
+
+ Subscribarr – Übersicht
+
+
+
+
+
+
+
+
+
+ kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }}
+
+
+ ⚙️ Einstellungen
+
+
+
+
+
+
+
Subscribarr
+
+
+
+
+
+
+
+ {% if show_series %}
+
+
Laufende Serien
+
+ {% for s in series_grouped %}
+
+
+ {% if s.seriesPoster %}
+

+ {% else %}
+

+ {% endif %}
+
+
+ {# sichere Episoden-JSON für Modal #}
+ {% with sid=s.seriesId|stringformat:"s" %}
+ {% with eid="eps-"|add:sid %}
+ {{ s.episodes|json_script:eid }}
+ {% endwith %}
+ {% endwith %}
+
+ {% empty %}
+
Keine Serien gefunden.
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if show_movies %}
+
+
Anstehende Filme
+
+ {% for m in movies %}
+
+ {% if m.posterUrl %}
+

+ {% else %}
+

+ {% endif %}
+
{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}
+
+ {% if m.inCinemas %}Kino: {% endif %}
+ {% if m.digitalRelease %}
Digital: {% endif %}
+ {% if m.physicalRelease %}
Disc: {% endif %}
+
+
+ {% empty %}
+
Keine Filme gefunden.
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
Kommende Episoden
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/arr_api/tests.py b/arr_api/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/arr_api/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/arr_api/urls.py b/arr_api/urls.py
new file mode 100644
index 0000000..277edad
--- /dev/null
+++ b/arr_api/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+from .views import SonarrAiringView, RadarrUpcomingMoviesView
+
+urlpatterns = [
+ path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"),
+ path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"),
+]
\ No newline at end of file
diff --git a/arr_api/views.py b/arr_api/views.py
new file mode 100644
index 0000000..537daa2
--- /dev/null
+++ b/arr_api/views.py
@@ -0,0 +1,114 @@
+from collections import defaultdict
+from django.shortcuts import render
+from django.views import View
+from django.contrib import messages
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework import status
+
+from settingspanel.models import AppSettings
+from .services import sonarr_calendar, radarr_calendar, ArrServiceError
+
+
+def _get_int(request, key, default):
+ try:
+ v = int(request.GET.get(key, default))
+ return max(1, min(365, v))
+ except (TypeError, ValueError):
+ return default
+
+def _arr_conf_from_db():
+ cfg = AppSettings.current()
+ return {
+ "sonarr_url": cfg.sonarr_url,
+ "sonarr_key": cfg.sonarr_api_key,
+ "radarr_url": cfg.radarr_url,
+ "radarr_key": cfg.radarr_api_key,
+ }
+
+
+class SonarrAiringView(APIView):
+ def get(self, request):
+ days = _get_int(request, "days", 30)
+ conf = _arr_conf_from_db()
+ try:
+ data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
+ return Response({"count": len(data), "results": data})
+ except ArrServiceError as e:
+ return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+
+
+class RadarrUpcomingMoviesView(APIView):
+ def get(self, request):
+ days = _get_int(request, "days", 60)
+ conf = _arr_conf_from_db()
+ try:
+ data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
+ return Response({"count": len(data), "results": data})
+ except ArrServiceError as e:
+ return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
+
+
+class ArrIndexView(View):
+ def get(self, request):
+ q = (request.GET.get("q") or "").lower().strip()
+ kind = (request.GET.get("kind") or "all").lower()
+ days = _get_int(request, "days", 30)
+
+ conf = _arr_conf_from_db()
+
+ eps, movies = [], []
+ # Sonarr robust laden
+ try:
+ eps = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
+ except ArrServiceError as e:
+ messages.error(request, f"Sonarr nicht erreichbar: {e}")
+
+ # Radarr robust laden
+ try:
+ movies = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
+ except ArrServiceError as e:
+ messages.error(request, f"Radarr nicht erreichbar: {e}")
+
+ # Suche
+ if q:
+ eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()]
+ movies = [m for m in movies if q in (m.get("title") or "").lower()]
+
+ # Gruppierung nach Serie
+ groups = defaultdict(lambda: {
+ "seriesId": None, "seriesTitle": None, "seriesPoster": None,
+ "seriesOverview": "", "seriesGenres": [], "episodes": [],
+ })
+ for e in eps:
+ sid = e["seriesId"]
+ g = groups[sid]
+ g["seriesId"] = sid
+ g["seriesTitle"] = e["seriesTitle"]
+ g["seriesPoster"] = g["seriesPoster"] or e.get("seriesPoster")
+ if not g["seriesOverview"] and e.get("seriesOverview"):
+ g["seriesOverview"] = e["seriesOverview"]
+ if not g["seriesGenres"] and e.get("seriesGenres"):
+ g["seriesGenres"] = e["seriesGenres"]
+ g["episodes"].append({
+ "episodeId": e["episodeId"],
+ "seasonNumber": e["seasonNumber"],
+ "episodeNumber": e["episodeNumber"],
+ "title": e["title"],
+ "airDateUtc": e["airDateUtc"],
+ })
+
+ series_grouped = []
+ for g in groups.values():
+ g["episodes"].sort(key=lambda x: (x["airDateUtc"] or ""))
+ series_grouped.append(g)
+
+ return render(request, "arr_api/index.html", {
+ "query": q,
+ "kind": kind,
+ "days": days,
+ "show_series": kind in ("all", "series"),
+ "show_movies": kind in ("all", "movies"),
+ "series_grouped": series_grouped,
+ "movies": movies,
+ })
\ No newline at end of file
diff --git a/cookies.txt b/cookies.txt
new file mode 100644
index 0000000..c31d989
--- /dev/null
+++ b/cookies.txt
@@ -0,0 +1,4 @@
+# Netscape HTTP Cookie File
+# https://curl.se/docs/http-cookies.html
+# This file was generated by libcurl! Edit at your own risk.
+
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..1f74944
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/settingspanel/__init__.py b/settingspanel/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/settingspanel/admin.py b/settingspanel/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/settingspanel/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/settingspanel/apps.py b/settingspanel/apps.py
new file mode 100644
index 0000000..a8bc231
--- /dev/null
+++ b/settingspanel/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SettingspanelConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'settingspanel'
diff --git a/settingspanel/forms.py b/settingspanel/forms.py
new file mode 100644
index 0000000..b3fc9d1
--- /dev/null
+++ b/settingspanel/forms.py
@@ -0,0 +1,33 @@
+from django import forms
+
+WIDE = {"class": "input-wide"}
+
+class ArrSettingsForm(forms.Form):
+ sonarr_url = forms.URLField(label="Sonarr URL", required=False,
+ widget=forms.URLInput(attrs=WIDE))
+ sonarr_api_key = forms.CharField(label="Sonarr API Key", required=False,
+ widget=forms.PasswordInput(render_value=True, attrs=WIDE))
+ radarr_url = forms.URLField(label="Radarr URL", required=False,
+ widget=forms.URLInput(attrs=WIDE))
+ radarr_api_key = forms.CharField(label="Radarr API Key", required=False,
+ widget=forms.PasswordInput(render_value=True, attrs=WIDE))
+
+class MailSettingsForm(forms.Form):
+ mail_host = forms.CharField(label="Mail Host", required=False)
+ mail_port = forms.IntegerField(label="Mail Port", required=False, min_value=1, max_value=65535)
+ mail_secure = forms.ChoiceField(
+ label="Sicherheit", required=False,
+ choices=[("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS")]
+ )
+ mail_user = forms.CharField(label="Mail Benutzer", required=False)
+ mail_password = forms.CharField(
+ label="Mail Passwort", required=False,
+ widget=forms.PasswordInput(render_value=True)
+ )
+ mail_from = forms.EmailField(label="Absender (From)", required=False)
+
+class AccountForm(forms.Form):
+ username = forms.CharField(label="Benutzername", required=False)
+ email = forms.EmailField(label="E-Mail", required=False)
+ new_password = forms.CharField(label="Neues Passwort", required=False, widget=forms.PasswordInput)
+ repeat_password = forms.CharField(label="Passwort wiederholen", required=False, widget=forms.PasswordInput)
diff --git a/settingspanel/migrations/0001_initial.py b/settingspanel/migrations/0001_initial.py
new file mode 100644
index 0000000..3638d87
--- /dev/null
+++ b/settingspanel/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.5 on 2025-08-08 23:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AppSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('singleton_id', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
+ ('sonarr_url', models.URLField(blank=True, null=True)),
+ ('sonarr_api_key', models.CharField(blank=True, max_length=255, null=True)),
+ ('radarr_url', models.URLField(blank=True, null=True)),
+ ('radarr_api_key', models.CharField(blank=True, max_length=255, null=True)),
+ ('mail_host', models.CharField(blank=True, max_length=255, null=True)),
+ ('mail_port', models.PositiveIntegerField(blank=True, null=True)),
+ ('mail_secure', models.CharField(blank=True, choices=[('', 'Kein TLS/SSL'), ('starttls', 'STARTTLS'), ('ssl', 'SSL/TLS')], max_length=10, null=True)),
+ ('mail_user', models.CharField(blank=True, max_length=255, null=True)),
+ ('mail_password', models.CharField(blank=True, max_length=255, null=True)),
+ ('mail_from', models.EmailField(blank=True, max_length=254, null=True)),
+ ('acc_username', models.CharField(blank=True, max_length=150, null=True)),
+ ('acc_email', models.EmailField(blank=True, max_length=254, null=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ ]
diff --git a/settingspanel/migrations/__init__.py b/settingspanel/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/settingspanel/models.py b/settingspanel/models.py
new file mode 100644
index 0000000..dc6e965
--- /dev/null
+++ b/settingspanel/models.py
@@ -0,0 +1,36 @@
+from django.db import models
+
+class AppSettings(models.Model):
+ # Singleton-Pattern über feste ID
+ singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
+
+ # Arr
+ sonarr_url = models.URLField(blank=True, null=True)
+ sonarr_api_key = models.CharField(max_length=255, blank=True, null=True)
+ radarr_url = models.URLField(blank=True, null=True)
+ radarr_api_key = models.CharField(max_length=255, blank=True, null=True)
+
+ # Mail
+ mail_host = models.CharField(max_length=255, blank=True, null=True)
+ mail_port = models.PositiveIntegerField(blank=True, null=True)
+ mail_secure = models.CharField(
+ max_length=10, blank=True, null=True,
+ choices=(("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS"))
+ )
+ mail_user = models.CharField(max_length=255, blank=True, null=True)
+ mail_password = models.CharField(max_length=255, blank=True, null=True)
+ mail_from = models.EmailField(blank=True, null=True)
+
+ # „Account“
+ acc_username = models.CharField(max_length=150, blank=True, null=True)
+ acc_email = models.EmailField(blank=True, null=True)
+
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return "AppSettings"
+
+ @classmethod
+ def current(cls):
+ obj, _ = cls.objects.get_or_create(singleton_id=1)
+ return obj
diff --git a/settingspanel/templates/settingspanel/settings.html b/settingspanel/templates/settingspanel/settings.html
new file mode 100644
index 0000000..ddceaad
--- /dev/null
+++ b/settingspanel/templates/settingspanel/settings.html
@@ -0,0 +1,350 @@
+{% load static %}
+
+
+
+
+
+
+ Einstellungen – Subscribarr
+
+
+
+
+
+
+
+ {% if messages %}
+
{% for m in messages %}
{{ m }}
{% endfor %}
+ {% endif %}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/settingspanel/tests.py b/settingspanel/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/settingspanel/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/settingspanel/urls.py b/settingspanel/urls.py
new file mode 100644
index 0000000..f35c73a
--- /dev/null
+++ b/settingspanel/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+from .views import SettingsView, test_connection
+
+app_name = "settingspanel"
+urlpatterns = [
+ path("", SettingsView.as_view(), name="index"),
+ path("test-connection/", test_connection, name="test_connection"),
+]
diff --git a/settingspanel/views.py b/settingspanel/views.py
new file mode 100644
index 0000000..ffe350a
--- /dev/null
+++ b/settingspanel/views.py
@@ -0,0 +1,83 @@
+from django.views import View
+from django.shortcuts import render, redirect
+from django.contrib import messages
+from .forms import ArrSettingsForm, MailSettingsForm, AccountForm
+from .models import AppSettings
+from django.http import JsonResponse
+import requests
+
+def test_connection(request):
+ kind = request.GET.get("kind") # "sonarr" | "radarr"
+ url = (request.GET.get("url") or "").strip()
+ key = (request.GET.get("key") or "").strip()
+ if kind not in ("sonarr", "radarr"):
+ return JsonResponse({"ok": False, "error": "Ungültiger Typ"}, status=400)
+ if not url or not key:
+ return JsonResponse({"ok": False, "error": "URL und API-Key erforderlich"}, status=400)
+
+ try:
+ r = requests.get(
+ f"{url.rstrip('/')}/api/v3/system/status",
+ headers={"X-Api-Key": key},
+ timeout=5
+ )
+ if r.status_code == 200:
+ return JsonResponse({"ok": True})
+ return JsonResponse({"ok": False, "error": f"HTTP {r.status_code}"})
+ except requests.RequestException as e:
+ return JsonResponse({"ok": False, "error": str(e)})
+
+class SettingsView(View):
+ template_name = "settingspanel/settings.html"
+
+ def get(self, request):
+ cfg = AppSettings.current()
+ return render(request, self.template_name, {
+ "arr_form": ArrSettingsForm(initial={
+ "sonarr_url": cfg.sonarr_url or "",
+ "sonarr_api_key": cfg.sonarr_api_key or "",
+ "radarr_url": cfg.radarr_url or "",
+ "radarr_api_key": cfg.radarr_api_key or "",
+ }),
+ "mail_form": MailSettingsForm(initial={
+ "mail_host": cfg.mail_host or "",
+ "mail_port": cfg.mail_port or "",
+ "mail_secure": cfg.mail_secure or "",
+ "mail_user": cfg.mail_user or "",
+ "mail_password": cfg.mail_password or "",
+ "mail_from": cfg.mail_from or "",
+ }),
+ "account_form": AccountForm(initial={
+ "username": cfg.acc_username or "",
+ "email": cfg.acc_email or "",
+ }),
+ })
+
+ def post(self, request):
+ arr_form = ArrSettingsForm(request.POST)
+ mail_form = MailSettingsForm(request.POST)
+ acc_form = AccountForm(request.POST)
+ if not (arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()):
+ return render(request, self.template_name, {
+ "arr_form": arr_form, "mail_form": mail_form, "account_form": acc_form
+ })
+
+ cfg = AppSettings.current()
+ cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
+ cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
+ cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None
+ cfg.radarr_api_key = arr_form.cleaned_data["radarr_api_key"] or None
+
+ cfg.mail_host = mail_form.cleaned_data["mail_host"] or None
+ cfg.mail_port = mail_form.cleaned_data["mail_port"] or None
+ cfg.mail_secure = mail_form.cleaned_data["mail_secure"] or ""
+ cfg.mail_user = mail_form.cleaned_data["mail_user"] or None
+ cfg.mail_password = mail_form.cleaned_data["mail_password"] or None
+ cfg.mail_from = mail_form.cleaned_data["mail_from"] or None
+
+ cfg.acc_username = acc_form.cleaned_data["username"] or None
+ cfg.acc_email = acc_form.cleaned_data["email"] or None
+
+ cfg.save()
+ messages.success(request, "Einstellungen gespeichert (DB).")
+ return redirect("settingspanel:index")
diff --git a/subscribarr/__init__.py b/subscribarr/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/subscribarr/asgi.py b/subscribarr/asgi.py
new file mode 100644
index 0000000..1a6df1c
--- /dev/null
+++ b/subscribarr/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for subscribarr project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings')
+
+application = get_asgi_application()
diff --git a/subscribarr/settings.py b/subscribarr/settings.py
new file mode 100644
index 0000000..28a73f6
--- /dev/null
+++ b/subscribarr/settings.py
@@ -0,0 +1,125 @@
+"""
+Django settings for subscribarr project.
+
+Generated by 'django-admin startproject' using Django 5.2.5.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.2/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'django-insecure-p^2cgzteh=p3ppya71l7+r3nuc=_w@2#fer4n7ckywt1$x%u&j'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'rest_framework',
+ 'arr_api',
+ 'settingspanel',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'subscribarr.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'subscribarr.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/5.2/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/5.2/howto/static-files/
+
+STATIC_URL = 'static/'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/subscribarr/urls.py b/subscribarr/urls.py
new file mode 100644
index 0000000..1df175e
--- /dev/null
+++ b/subscribarr/urls.py
@@ -0,0 +1,26 @@
+"""
+URL configuration for subscribarr project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path, include
+from arr_api.views import ArrIndexView
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path("", ArrIndexView.as_view(), name="home"),
+ path("settings/", include("settingspanel.urls")),
+ path("api/", include("arr_api.urls")),
+]
diff --git a/subscribarr/wsgi.py b/subscribarr/wsgi.py
new file mode 100644
index 0000000..cf0f8b0
--- /dev/null
+++ b/subscribarr/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for subscribarr project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings')
+
+application = get_wsgi_application()