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