This commit is contained in:
2025-08-10 12:43:48 +02:00
parent 5797ac22c8
commit d4b811dbad
31 changed files with 2004 additions and 172 deletions

176
.gitignore vendored
View File

@@ -1,176 +1,8 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.pyc
*$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
.env .env
.venv db.sqlite3
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .pytest_cache/
dmypy.json .coverage
# 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

16
Pipfile Normal file
View File

@@ -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"

212
Pipfile.lock generated Normal file
View File

@@ -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": {}
}

0
arr_api/__init__.py Normal file
View File

3
arr_api/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
arr_api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ArrApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'arr_api'

View File

3
arr_api/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

133
arr_api/services.py Normal file
View File

@@ -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)]

View File

@@ -0,0 +1,738 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subscribarr Übersicht</title>
<style>
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
h1 {
margin: 4px 0 12px;
font-size: clamp(1.2rem, 2.5vw, 1.6rem);
}
/* Controls */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.controls form {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 220px;
}
.controls input[type=text] {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
min-width: 0;
}
.controls button[type=submit] {
padding: 10px 14px;
border-radius: 10px;
border: 0;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.seg {
display: inline-flex;
background: #0f0f17;
border: 1px solid #28293a;
border-radius: 10px;
overflow: hidden;
}
.seg a {
padding: 8px 12px;
color: var(--text);
text-decoration: none;
}
.seg a.active {
background: var(--accent);
color: #fff;
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
@media (min-width: 900px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
overflow: hidden;
display: flex;
gap: 12px;
padding: 12px;
transition: transform .08s ease, border-color .08s;
cursor: pointer;
}
.card:active,
.card:hover {
transform: translateY(-2px);
border-color: #2a2b44;
}
.poster {
width: 110px;
height: 165px;
background: #222233;
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.meta {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episodes {
max-height: 210px;
overflow: auto;
padding-right: 6px;
}
.ep {
font-size: 0.92rem;
padding: 6px 0;
border-bottom: 1px dashed #25263a;
}
.muted {
color: var(--muted);
font-size: 0.9rem;
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px;
}
.movie-card img {
width: 100%;
border-radius: 8px;
margin-bottom: 8px;
display: block;
height: auto;
}
.section {
margin-top: 22px;
}
.section h2 {
font-size: 1.1rem;
margin: 12px 0;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10, 12, 20, .55);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 12px;
}
.modal {
width: min(960px, 100%);
max-height: 92vh;
overflow: auto;
background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%);
border: 1px solid #2a2b44;
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .6);
}
.modal-header {
position: sticky;
grid-template-columns: auto 1fr auto;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: 130px 1fr auto;
gap: 14px;
align-items: center;
padding: 16px;
background: rgba(13, 15, 22, .85);
backdrop-filter: blur(4px);
border-bottom: 1px solid #20223a;
}
.m-poster {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 130px;
height: 195px;
border-radius: 10px;
overflow: hidden;
background: #222233;
}
.m-poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.m-title {
font-size: 1.25rem;
font-weight: 750;
line-height: 1.2;
margin-bottom: 6px;
}
.m-sub {
color: var(--muted);
font-size: .92rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.badge {
padding: 4px 8px;
border-radius: 999px;
background: #171a26;
border: 1px solid #2a2b44;
font-size: .82rem;
color: #cfd3ea;
}
.modal-close {
margin-left: auto;
align-self: start;
background: #1a1f33;
border: 1px solid #2a2b44;
color: #c9cbe3;
width: 36px;
height: 36px;
justify-self: end;
border-radius: 10px;
cursor: pointer;
display: grid;
place-items: center;
font-size: 1.4rem;
line-height: 1;
transition: transform .08s ease, background .12s ease, border-color .12s ease;
}
.modal-close:hover {
background: #243055;
border-color: #3b4aa0;
transform: translateY(-1px);
}
.modal-body {
padding: 16px;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 900px) {
.modal-body {
grid-template-columns: 1.2fr .8fr;
}
}
.section-block {
background: #101327;
border: 1px solid #20223a;
border-radius: 12px;
padding: 14px;
}
.section-title {
font-size: 1rem;
font-weight: 650;
margin: 0 0 8px;
}
.section-divider {
height: 1px;
background: #20223a;
margin: 10px 0;
opacity: .9;
}
.ep-row {
border-bottom: 1px dashed #262947;
padding: 8px 0;
font-size: .94rem;
}
.ep-row:last-child {
border-bottom: 0;
}
/* control */
.controls input[type=number] {
width: 90px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
-moz-appearance: textfield;
}
.btn-subscribe {
padding: 8px 14px;
border-radius: 10px;
background: #1f6f3a;
border: 1px solid #2a2b34;
color: #fff;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .08s ease;
}
.btn-subscribe:hover {
background: #2b8f4d;
}
.btn-subscribe:active {
transform: translateY(1px);
}
.subscribed {
outline: 3px solid #1f6f3a;
/* grüne Markierung am Element */
outline-offset: 2px;
}
.controls input[type=number]::-webkit-outer-spin-button,
.controls input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.controls input[type=number]:focus {
border-color: var(--accent);
outline: none;
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px;
cursor: pointer;
/* klickbar */
transition: transform .08s ease, border-color .08s;
/* wie .card */
}
.movie-card:hover,
.movie-card:focus-within {
transform: translateY(-2px);
border-color: #2a2b44;
}
.movie-card:active {
transform: translateY(0);
/* kleiner Tap-Feedback */
}
</style>
</head>
<body>
<div class="topbar" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div></div>
<div style="display:flex;gap:8px;align-items:center">
<div class="debug" title="Debug"
style="padding:8px 10px;border:1px solid #2a2a34;border-radius:10px;background:#111119;color:#cfd3ea;font-size:.9rem;">
kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }}
</div>
<a href="/settings/" class="btn"
style="padding:8px 12px;border-radius:10px;border:1px solid #2a2a34;background:#111119;color:#fff;text-decoration:none">
⚙️ Einstellungen
</a>
</div>
</div>
<div class="wrap">
<h1>Subscribarr</h1>
<div class="controls">
<form method="get" class="controls-form">
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}"
title="Zeitraum in Tagen">
<button type="submit">Suchen</button>
</form>
<nav class="seg" aria-label="Typ filtern">
{% with qs=query|urlencode %}
<a href="?kind=all&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'all' %}active{% endif %}">Alle</a>
<a href="?kind=series&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'series' %}active{% endif %}">Serien</a>
<a href="?kind=movies&days={{ days }}{% if qs %}&q={{ qs }}{% endif %}"
class="{% if kind == 'movies' %}active{% endif %}">Filme</a>
{% endwith %}
</nav>
</div>
{% if show_series %}
<div class="section">
<h2>Laufende Serien</h2>
<div class="grid">
{% for s in series_grouped %}
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
data-poster="{{ s.seriesPoster|default:'' }}"
data-overview="{{ s.seriesOverview|default:''|escape }}">
<div class="poster">
{% if s.seriesPoster %}
<img src="{{ s.seriesPoster }}" alt="{{ s.seriesTitle }}">
{% else %}
<img src="https://via.placeholder.com/110x165?text=No+Poster" alt="">
{% endif %}
</div>
<div class="meta">
<div class="title" title="{{ s.seriesTitle }}">{{ s.seriesTitle }}</div>
<div class="episodes">
{% for e in s.episodes %}
<div class="ep">
S{{ e.seasonNumber }}E{{ e.episodeNumber }} — {{ e.title|default:"(tba)" }}<br>
<span class="muted" data-dt="{{ e.airDateUtc|default:'' }}"></span>
</div>
{% empty %}
<div class="muted">Keine kommenden Episoden.</div>
{% endfor %}
</div>
</div>
{# sichere Episoden-JSON für Modal #}
{% with sid=s.seriesId|stringformat:"s" %}
{% with eid="eps-"|add:sid %}
{{ s.episodes|json_script:eid }}
{% endwith %}
{% endwith %}
</div>
{% empty %}
<p class="muted">Keine Serien gefunden.</p>
{% endfor %}
</div>
</div>
{% endif %}
{% if show_movies %}
<div class="section">
<h2>Anstehende Filme</h2>
<div class="grid">
{% for m in movies %}
<div class="movie-card" data-kind="movie" data-title="{{ m.title|escape }}"
data-poster="{{ m.posterUrl|default:'' }}" data-overview="{{ m.overview|default:''|escape }}">
{% if m.posterUrl %}
<img src="{{ m.posterUrl }}" alt="{{ m.title }}">
{% else %}
<img src="https://via.placeholder.com/300x450?text=No+Poster" alt="">
{% endif %}
<div class="title">{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}</div>
<div class="muted">
{% if m.inCinemas %}Kino: <span data-dt="{{ m.inCinemas }}"></span>{% endif %}
{% if m.digitalRelease %}<br>Digital: <span data-dt="{{ m.digitalRelease }}"></span>{% endif %}
{% if m.physicalRelease %}<br>Disc: <span data-dt="{{ m.physicalRelease }}"></span>{% endif %}
</div>
</div>
{% empty %}
<p class="muted">Keine Filme gefunden.</p>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Modal -->
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal">
<div class="modal-header">
<div class="m-poster-wrap">
<div class="m-poster"><img id="mPoster" alt=""></div>
<button id="subscribeBtn" class="btn-subscribe" type="button">Subscribe</button>
</div>
<div>
<div id="mTitle" class="m-title"></div>
<div id="mSub" class="m-sub"></div>
<div id="mBadges" class="badges"></div>
</div>
<button class="modal-close" title="Schließen" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div class="section-block">
<div class="section-title">Beschreibung</div>
<div id="mOverview" class="desc muted"></div>
</div>
<div class="section-block">
<div class="section-title">Kommende Episoden</div>
<div class="section-divider"></div>
<div id="mEpisodes"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
// ===== Helpers =====
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// ===== Modal-Elemente =====
const backdrop = $("#modalBackdrop");
const closeBtn = backdrop.querySelector(".modal-close");
const mPoster = $("#mPoster");
const mTitle = $("#mTitle");
const mOverview = $("#mOverview");
const mEpisodes = $("#mEpisodes");
const mBadges = $("#mBadges");
const mSub = $("#mSub");
const epSection = mEpisodes.closest(".section-block");
const subscribeBtn = $("#subscribeBtn");
let lastClickedCard = null;
// ===== Modal open/close =====
function openModal() {
backdrop.style.display = "flex";
backdrop.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeModal() {
backdrop.style.display = "none";
backdrop.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
closeBtn.addEventListener("click", closeModal);
backdrop.addEventListener("click", e => { if (e.target === backdrop) closeModal(); });
window.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); });
// ===== Subscribe-Only-UI (mit localStorage) =====
function subKey(card) {
if (!card) return null;
if (card.classList.contains("card") && card.dataset.seriesId) return "series:" + card.dataset.seriesId;
return "movie:" + (card.dataset.title || "");
}
function loadSub(card) {
const k = subKey(card);
return k ? localStorage.getItem("sub:" + k) === "1" : false;
}
function saveSub(card, on) {
const k = subKey(card);
if (!k) return;
if (on) localStorage.setItem("sub:" + k, "1");
else localStorage.removeItem("sub:" + k);
}
function applySubUI(card, on) {
if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS)
if (subscribeBtn) {
subscribeBtn.textContent = on ? "Unsubscribe" : "Subscribe";
subscribeBtn.setAttribute("aria-pressed", on ? "true" : "false");
}
}
// Beim Laden: gespeicherten Zustand auf alle Karten anwenden
$$(".card, .movie-card").forEach(c => applySubUI(c, loadSub(c)));
// ===== Serien-Karten öffnen =====
$$(".card").forEach(card => {
card.addEventListener("click", () => {
lastClickedCard = card;
const id = card.dataset.seriesId;
const title = card.dataset.title || "";
const poster = card.dataset.poster || "";
const overview = card.dataset.overview || "";
// Episoden aus eingebettetem JSON <script id="eps-<id>">
let episodes = [];
const script = document.getElementById("eps-" + id);
if (script) { try { episodes = JSON.parse(script.textContent); } catch { } }
// Modal befüllen
mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
mSub.textContent = episodes.length
? `${episodes.length} kommende Episode(n)`
: "Keine kommenden Episoden";
// Genres-Badges, falls data-genres vorhanden
mBadges.innerHTML = "";
if (card.dataset.genres) {
card.dataset.genres.split(",").map(s => s.trim()).filter(Boolean).forEach(g => {
const b = document.createElement("span");
b.className = "badge";
b.textContent = g;
mBadges.appendChild(b);
});
}
// Episodenbereich
epSection.style.display = "";
mEpisodes.innerHTML = "";
if (!episodes.length) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = "—";
mEpisodes.appendChild(p);
} else {
episodes.forEach(e => {
const row = document.createElement("div");
row.className = "ep-row";
const dt = e.airDateUtc ? new Date(e.airDateUtc) : null;
const when = dt && !isNaN(dt) ? dt.toLocaleString() : "-";
row.innerHTML = `<strong>S${e.seasonNumber}E${e.episodeNumber}</strong> — ${e.title ?? "(tba)"}<br><span class="muted">${when}</span>`;
mEpisodes.appendChild(row);
});
}
// Subscribe-UI für diese Karte setzen
applySubUI(lastClickedCard, loadSub(lastClickedCard));
openModal();
});
});
// ===== Film-Karten öffnen =====
$$(".movie-card").forEach(card => {
card.addEventListener("click", () => {
lastClickedCard = card;
const title = card.dataset.title || "";
const poster = card.dataset.poster || "";
const overview = card.dataset.overview || "";
mTitle.textContent = title;
mPoster.src = poster || "https://via.placeholder.com/130x195?text=No+Poster";
mPoster.alt = title;
mOverview.textContent = overview || "Keine Beschreibung verfügbar.";
mSub.textContent = "";
mBadges.innerHTML = "";
// Episodenbereich ausblenden
epSection.style.display = "none";
mEpisodes.innerHTML = "";
// Subscribe-UI für diese Karte setzen
applySubUI(lastClickedCard, loadSub(lastClickedCard));
openModal();
});
});
// ===== Subscribe-Button im Modal toggelt nur UI + localStorage =====
if (subscribeBtn) {
subscribeBtn.addEventListener("click", () => {
if (!lastClickedCard) return;
const now = !loadSub(lastClickedCard);
saveSub(lastClickedCard, now);
applySubUI(lastClickedCard, now);
});
}
// ===== Datumsangaben in der Übersicht formatieren =====
document.querySelectorAll("[data-dt]").forEach(el => {
const v = el.getAttribute("data-dt");
if (!v) return;
const d = new Date(v);
el.textContent = isNaN(d) ? v : d.toLocaleString();
});
})();
</script>
</body>
</html>

3
arr_api/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

7
arr_api/urls.py Normal file
View File

@@ -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"),
]

114
arr_api/views.py Normal file
View File

@@ -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,
})

4
cookies.txt Normal file
View File

@@ -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.

22
manage.py Executable file
View File

@@ -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()

View File

3
settingspanel/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
settingspanel/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SettingspanelConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'settingspanel'

33
settingspanel/forms.py Normal file
View File

@@ -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)

View File

@@ -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)),
],
),
]

View File

36
settingspanel/models.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,350 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title>
<style>
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
* {
box-sizing: border-box
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
margin: 0
}
.wrap {
max-width: 1000px;
margin: 0 auto;
padding: 16px
}
a {
color: #cfd3ea;
text-decoration: none
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer
}
.btn-primary {
background: var(--accent);
border-color: transparent
}
.grid {
display: grid;
gap: 16px
}
@media(min-width:900px) {
.grid {
grid-template-columns: 1fr 1fr
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 14px
}
.card h2 {
margin: 0 0 10px;
font-size: 1.05rem
}
.row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
/* <= statt 160px 1fr */
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.row label {
color: #c9cbe3
}
.row input,
.row select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text)
}
.help {
color: var(--muted);
font-size: .9rem
}
.msgs {
margin-bottom: 10px
}
.msg {
background: #0f1425;
border: 1px solid #283058;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px
}
.input-wide {
width: 100% !important;
max-width: 100%;
min-width: 0;
display: block;
}
.row input,
.row select,
.row textarea {
width: 100%;
max-width: 100%;
min-width: 0;
display: block;
}
/* falls du passwort/URL Felder extra stylen willst, gleicher Fix */
.inline>input,
.inline>.django-url,
/* falls Widget eine Klasse rendert */
.inline>.django-password {
min-width: 0;
width: 100%;
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 220px;
justify-content: flex-end;
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer;
}
.btn:disabled {
opacity: .6;
cursor: default;
}
.badge {
padding: 6px 10px;
border-radius: 999px;
font-size: .85rem;
border: 1px solid #2a2a34;
background: #111119;
color: #cfd3ea;
white-space: nowrap;
/* eine Zeile */
max-width: 140px;
/* begrenzt die Breite */
overflow: hidden;
text-overflow: ellipsis;
/* falls doch Text drin ist */
}
.badge.ok {
border-color: #1f6f3a;
background: #10331f;
color: #a7e3bd;
}
.badge.err {
border-color: #6f1f2a;
background: #341016;
color: #f1a3b0;
}
.badge.muted {
opacity: .8;
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div><a href="/" class="btn">← Zurück</a></div>
<div><strong>Einstellungen</strong></div>
<div></div>
</div>
{% if messages %}
<div class="msgs">{% for m in messages %}<div class="msg">{{ m }}</div>{% endfor %}</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="grid">
<div class="card">
<h2>Sonarr & Radarr</h2>
<div class="row">
<label>Sonarr URL</label>
<div class="inline">
<div class="field">{{ arr_form.sonarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('sonarr', this)">Test
Sonarr</button>
<span id="sonarrStatus" class="badge muted"></span>
</div>
</div>
</div>
<div class="row">
<label>Sonarr API Key</label>
{{ arr_form.sonarr_api_key }}
</div>
<div class="row">
<label>Radarr URL</label>
<div class="inline">
<div class="field">{{ arr_form.radarr_url }}</div>
<div class="inline-actions">
<button class="btn" type="button" onclick="testConnection('radarr', this)">Test
Radarr</button>
<span id="radarrStatus" class="badge muted"></span>
</div>
</div>
</div>
<div class="row">
<label>Radarr API Key</label>
{{ arr_form.radarr_api_key }}
</div>
<div class="help">Klicke „Test …“, um die Verbindung gegen <code>/api/v3/system/status</code> zu
prüfen.</div>
</div>
<div class="card">
<h2>Mailserver</h2>
<div class="row"><label>Host</label>{{ mail_form.mail_host }}</div>
<div class="row"><label>Port</label>{{ mail_form.mail_port }}</div>
<div class="row"><label>Sicherheit</label>{{ mail_form.mail_secure }}</div>
<div class="row"><label>Benutzer</label>{{ mail_form.mail_user }}</div>
<div class="row"><label>Passwort</label>{{ mail_form.mail_password }}</div>
<div class="row"><label>Absender</label>{{ mail_form.mail_from }}</div>
</div>
<div class="card">
<h2>Konto</h2>
<div class="row"><label>Benutzername</label>{{ account_form.username }}</div>
<div class="row"><label>E-Mail</label>{{ account_form.email }}</div>
<div class="row"><label>Neues Passwort</label>{{ account_form.new_password }}</div>
<div class="row"><label>Passwort wiederholen</label>{{ account_form.repeat_password }}</div>
<div class="help">Nur Oberfläche Umsetzung Passwortänderung später.</div>
</div>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" type="submit">Speichern</button>
</div>
</form>
</div>
<script>
function testConnection(kind) {
const url = document.querySelector(`input[name="${kind}_url"]`).value;
const key = document.querySelector(`input[name="${kind}_api_key"]`).value;
fetch(`/settings/test-connection/?kind=${kind}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json())
.then(data => {
if (data.ok) {
alert(kind + " Verbindung erfolgreich!");
} else {
alert(kind + " Fehler: " + data.error);
}
})
.catch(err => alert(kind + " Fehler: " + err));
}
function setBadge(kind, state, text, tooltip) {
const el = document.getElementById(kind + "Status");
if (!el) return;
el.classList.remove("ok", "err", "muted");
el.title = tooltip || ""; // voller Fehlertext im Tooltip
if (state === "ok") {
el.classList.add("ok");
el.textContent = "Verbunden";
} else if (state === "err") {
el.classList.add("err");
el.textContent = "Fehler";
} else {
el.classList.add("muted");
el.textContent = "—";
}
}
function testConnection(kind, btnEl) {
const urlEl = document.querySelector(`input[name="${kind}_url"]`);
const keyEl = document.querySelector(`input[name="${kind}_api_key"]`);
const url = urlEl ? urlEl.value.trim() : "";
const key = keyEl ? keyEl.value.trim() : "";
setBadge(kind, "muted", "Teste…");
if (btnEl) { btnEl.disabled = true; }
fetch(`/settings/test-connection/?kind=${encodeURIComponent(kind)}&url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}`)
.then(r => r.json())
.then(data => {
if (data.ok) {
setBadge(kind, "ok", "Verbunden", "");
} else {
setBadge(kind, "err", "Fehler", data.error || "Unbekannter Fehler");
}
})
.catch(err => setBadge(kind, "err", "Fehler", String(err)))
.finally(() => { if (btnEl) btnEl.disabled = false; });
}
</script>
</body>
</html>

3
settingspanel/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
settingspanel/urls.py Normal file
View File

@@ -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"),
]

83
settingspanel/views.py Normal file
View File

@@ -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")

0
subscribarr/__init__.py Normal file
View File

16
subscribarr/asgi.py Normal file
View File

@@ -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()

125
subscribarr/settings.py Normal file
View File

@@ -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'

26
subscribarr/urls.py Normal file
View File

@@ -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")),
]

16
subscribarr/wsgi.py Normal file
View File

@@ -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()