base
This commit is contained in:
176
.gitignore
vendored
176
.gitignore
vendored
@@ -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
16
Pipfile
Normal 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
212
Pipfile.lock
generated
Normal 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
0
arr_api/__init__.py
Normal file
3
arr_api/admin.py
Normal file
3
arr_api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
6
arr_api/apps.py
Normal file
6
arr_api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ArrApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'arr_api'
|
0
arr_api/migrations/__init__.py
Normal file
0
arr_api/migrations/__init__.py
Normal file
3
arr_api/models.py
Normal file
3
arr_api/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
133
arr_api/services.py
Normal file
133
arr_api/services.py
Normal 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)]
|
738
arr_api/templates/arr_api/index.html
Normal file
738
arr_api/templates/arr_api/index.html
Normal 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">×</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
3
arr_api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
7
arr_api/urls.py
Normal file
7
arr_api/urls.py
Normal 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
114
arr_api/views.py
Normal 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
4
cookies.txt
Normal 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
22
manage.py
Executable 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()
|
0
settingspanel/__init__.py
Normal file
0
settingspanel/__init__.py
Normal file
3
settingspanel/admin.py
Normal file
3
settingspanel/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
6
settingspanel/apps.py
Normal file
6
settingspanel/apps.py
Normal 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
33
settingspanel/forms.py
Normal 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)
|
34
settingspanel/migrations/0001_initial.py
Normal file
34
settingspanel/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
settingspanel/migrations/__init__.py
Normal file
0
settingspanel/migrations/__init__.py
Normal file
36
settingspanel/models.py
Normal file
36
settingspanel/models.py
Normal 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
|
350
settingspanel/templates/settingspanel/settings.html
Normal file
350
settingspanel/templates/settingspanel/settings.html
Normal 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
3
settingspanel/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
8
settingspanel/urls.py
Normal file
8
settingspanel/urls.py
Normal 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
83
settingspanel/views.py
Normal 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
0
subscribarr/__init__.py
Normal file
16
subscribarr/asgi.py
Normal file
16
subscribarr/asgi.py
Normal 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
125
subscribarr/settings.py
Normal 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
26
subscribarr/urls.py
Normal 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
16
subscribarr/wsgi.py
Normal 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()
|
Reference in New Issue
Block a user