multiuser/subscriptions/notifications
This commit is contained in:
		
							
								
								
									
										98
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										98
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,8 +1,94 @@ | ||||
| # Byte-compiled / optimized / DLL files | ||||
| __pycache__/ | ||||
| *.pyc | ||||
| .env | ||||
| db.sqlite3 | ||||
| .mypy_cache/ | ||||
| .pytest_cache/ | ||||
| .coverage | ||||
| *.py[cod] | ||||
| *$py.class | ||||
|  | ||||
| # C extensions | ||||
| *.so | ||||
|  | ||||
| # Distribution / packaging | ||||
| .Python | ||||
| build/ | ||||
| develop-eggs/ | ||||
| dist/ | ||||
| downloads/ | ||||
| eggs/ | ||||
| .eggs/ | ||||
| lib/ | ||||
| lib64/ | ||||
| parts/ | ||||
| sdist/ | ||||
| var/ | ||||
| wheels/ | ||||
| share/python-wheels/ | ||||
| *.egg-info/ | ||||
| .installed.cfg | ||||
| *.egg | ||||
| MANIFEST | ||||
|  | ||||
| # Virtual environments | ||||
| .env | ||||
| .venv | ||||
| env/ | ||||
| venv/ | ||||
| ENV/ | ||||
| env.bak/ | ||||
| venv.bak/ | ||||
|  | ||||
| # PyInstaller | ||||
| *.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 | ||||
| *.log | ||||
| local_settings.py | ||||
| staticfiles/ | ||||
| media/ | ||||
|  | ||||
| # Database | ||||
| /db.sqlite3 | ||||
|  | ||||
| # Environment files | ||||
| .env.local | ||||
| .env.*.local | ||||
|  | ||||
| # mypy | ||||
| .mypy_cache/ | ||||
| .dmypy.json | ||||
| dmypy.json | ||||
|  | ||||
| # IDEs / Editors | ||||
| .vscode/ | ||||
| .idea/ | ||||
|  | ||||
| # OS | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| # Project specific | ||||
| Subscribarr/cookies.txt | ||||
| ssh-config/ | ||||
|  | ||||
|   | ||||
							
								
								
									
										45
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| # syntax=docker/dockerfile:1 | ||||
| FROM python:3.13-slim | ||||
|  | ||||
| ENV PYTHONDONTWRITEBYTECODE=1 \ | ||||
|     PYTHONUNBUFFERED=1 | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| # System deps (include cron) | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     build-essential \ | ||||
|     curl \ | ||||
|     cron \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
| # Copy project | ||||
| COPY Pipfile Pipfile.lock /app/ | ||||
| RUN pip install pipenv && PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy --system | ||||
|  | ||||
| COPY . /app | ||||
|  | ||||
| # Optional non-root user (not used as default to allow cron) | ||||
| RUN useradd -ms /bin/bash app && chown -R app:app /app | ||||
| # USER app  # keep running as root to manage cron | ||||
|  | ||||
| # Runtime env defaults | ||||
| ENV DJANGO_DEBUG=true \ | ||||
|     DJANGO_ALLOWED_HOSTS=* \ | ||||
|     DB_PATH=/app/data/db.sqlite3 \ | ||||
|     NOTIFICATIONS_ALLOW_DUPLICATES=false \ | ||||
|     CRON_SCHEDULE="*/30 * * * *" \ | ||||
|     ADMIN_USERNAME= \ | ||||
|     ADMIN_PASSWORD= \ | ||||
|     ADMIN_EMAIL= | ||||
|  | ||||
| # create data dir for sqlite | ||||
| RUN mkdir -p /app/data | ||||
|  | ||||
| # Entrypoint script | ||||
| COPY docker/entrypoint.sh /entrypoint.sh | ||||
| RUN chmod +x /entrypoint.sh | ||||
|  | ||||
| EXPOSE 8000 | ||||
|  | ||||
| CMD ["/entrypoint.sh"] | ||||
							
								
								
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -4,11 +4,12 @@ verify_ssl = true | ||||
| name = "pypi" | ||||
|  | ||||
| [packages] | ||||
| django = "*" | ||||
| django-environ = "*" | ||||
| djangorestframework = "*" | ||||
| requests = "*" | ||||
| python-dateutil = "*" | ||||
| django = "*" | ||||
| jellyfin-apiclient-python = "*" | ||||
|  | ||||
| [dev-packages] | ||||
|  | ||||
|   | ||||
							
								
								
									
										192
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										192
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f" | ||||
|             "sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
| @@ -34,101 +34,88 @@ | ||||
|         }, | ||||
|         "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" | ||||
|                 "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", | ||||
|                 "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", | ||||
|                 "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", | ||||
|                 "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", | ||||
|                 "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", | ||||
|                 "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", | ||||
|                 "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", | ||||
|                 "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", | ||||
|                 "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", | ||||
|                 "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", | ||||
|                 "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", | ||||
|                 "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", | ||||
|                 "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", | ||||
|                 "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", | ||||
|                 "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", | ||||
|                 "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", | ||||
|                 "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", | ||||
|                 "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", | ||||
|                 "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", | ||||
|                 "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", | ||||
|                 "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", | ||||
|                 "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", | ||||
|                 "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", | ||||
|                 "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", | ||||
|                 "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", | ||||
|                 "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", | ||||
|                 "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", | ||||
|                 "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", | ||||
|                 "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", | ||||
|                 "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", | ||||
|                 "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", | ||||
|                 "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", | ||||
|                 "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", | ||||
|                 "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", | ||||
|                 "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", | ||||
|                 "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", | ||||
|                 "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", | ||||
|                 "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", | ||||
|                 "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", | ||||
|                 "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", | ||||
|                 "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", | ||||
|                 "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", | ||||
|                 "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", | ||||
|                 "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", | ||||
|                 "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", | ||||
|                 "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", | ||||
|                 "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", | ||||
|                 "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", | ||||
|                 "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", | ||||
|                 "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", | ||||
|                 "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", | ||||
|                 "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", | ||||
|                 "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", | ||||
|                 "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", | ||||
|                 "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", | ||||
|                 "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", | ||||
|                 "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", | ||||
|                 "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", | ||||
|                 "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", | ||||
|                 "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", | ||||
|                 "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", | ||||
|                 "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", | ||||
|                 "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", | ||||
|                 "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", | ||||
|                 "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", | ||||
|                 "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", | ||||
|                 "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", | ||||
|                 "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", | ||||
|                 "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", | ||||
|                 "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", | ||||
|                 "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", | ||||
|                 "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", | ||||
|                 "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", | ||||
|                 "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", | ||||
|                 "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", | ||||
|                 "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", | ||||
|                 "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", | ||||
|                 "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", | ||||
|                 "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.7'", | ||||
|             "version": "==3.4.2" | ||||
|             "version": "==3.4.3" | ||||
|         }, | ||||
|         "django": { | ||||
|             "hashes": [ | ||||
| @@ -165,6 +152,15 @@ | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==3.10" | ||||
|         }, | ||||
|         "jellyfin-apiclient-python": { | ||||
|             "hashes": [ | ||||
|                 "sha256:b666c8d175b36f2ce9e6020c13821eb1aa104a585bfbba58f6790fa15f358b40", | ||||
|                 "sha256:f5e3dc4ea06a80d26859a62ace7c3ab26f762063a3032f9109f4cea2ed8ac5de" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==1.11.0" | ||||
|         }, | ||||
|         "python-dateutil": { | ||||
|             "hashes": [ | ||||
|                 "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", | ||||
| @@ -206,6 +202,14 @@ | ||||
|             ], | ||||
|             "markers": "python_version >= '3.9'", | ||||
|             "version": "==2.5.0" | ||||
|         }, | ||||
|         "websocket-client": { | ||||
|             "hashes": [ | ||||
|                 "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", | ||||
|                 "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.8'", | ||||
|             "version": "==1.8.0" | ||||
|         } | ||||
|     }, | ||||
|     "develop": {} | ||||
|   | ||||
							
								
								
									
										0
									
								
								accounts/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								accounts/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										3
									
								
								accounts/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								accounts/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| # Register your models here. | ||||
							
								
								
									
										6
									
								
								accounts/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								accounts/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AccountsConfig(AppConfig): | ||||
|     default_auto_field = 'django.db.models.BigAutoField' | ||||
|     name = 'accounts' | ||||
							
								
								
									
										24
									
								
								accounts/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								accounts/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| from django import forms | ||||
| from django.contrib.auth.forms import UserCreationForm, UserChangeForm | ||||
| from .models import User | ||||
|  | ||||
| class CustomUserCreationForm(UserCreationForm): | ||||
|     email = forms.EmailField(required=True) | ||||
|      | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ('username', 'email', 'password1', 'password2') | ||||
|  | ||||
| class CustomUserChangeForm(UserChangeForm): | ||||
|     password = None  # Passwort-Änderung über extra Formular | ||||
|      | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ('email',) | ||||
|         widgets = { | ||||
|             'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}), | ||||
|         } | ||||
|  | ||||
| class JellyfinLoginForm(forms.Form): | ||||
|     username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'})) | ||||
|     password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'})) | ||||
							
								
								
									
										45
									
								
								accounts/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								accounts/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 11:59 | ||||
|  | ||||
| import django.contrib.auth.models | ||||
| import django.contrib.auth.validators | ||||
| import django.utils.timezone | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('auth', '0012_alter_user_first_name_max_length'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='User', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('password', models.CharField(max_length=128, verbose_name='password')), | ||||
|                 ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), | ||||
|                 ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), | ||||
|                 ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), | ||||
|                 ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), | ||||
|                 ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), | ||||
|                 ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), | ||||
|                 ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), | ||||
|                 ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), | ||||
|                 ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), | ||||
|                 ('bio', models.TextField(blank=True, max_length=500)), | ||||
|                 ('is_admin', models.BooleanField(default=False)), | ||||
|                 ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), | ||||
|                 ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'user', | ||||
|                 'verbose_name_plural': 'users', | ||||
|             }, | ||||
|             managers=[ | ||||
|                 ('objects', django.contrib.auth.models.UserManager()), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,28 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 12:38 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='jellyfin_server', | ||||
|             field=models.CharField(blank=True, max_length=200, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='jellyfin_token', | ||||
|             field=models.CharField(blank=True, max_length=500, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='jellyfin_user_id', | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										0
									
								
								accounts/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								accounts/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										39
									
								
								accounts/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								accounts/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| from django.db import models | ||||
| from django.contrib.auth.models import AbstractUser | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| class User(AbstractUser): | ||||
|     """ | ||||
|     Custom User Model mit zusätzlichen Feldern und Berechtigungen. | ||||
|     Normale User können nur ihre eigenen Daten bearbeiten. | ||||
|     Admin-User können alles. | ||||
|     """ | ||||
|     email = models.EmailField(_("email address"), unique=True) | ||||
|     bio = models.TextField(max_length=500, blank=True) | ||||
|     is_admin = models.BooleanField(default=False) | ||||
|      | ||||
|     # Jellyfin fields | ||||
|     jellyfin_user_id = models.CharField(max_length=100, blank=True, null=True) | ||||
|     jellyfin_token = models.CharField(max_length=500, blank=True, null=True) | ||||
|     jellyfin_server = models.CharField(max_length=200, blank=True, null=True) | ||||
|      | ||||
|     def check_jellyfin_admin(self): | ||||
|         """Check if user is Jellyfin admin on the server""" | ||||
|         from accounts.utils import JellyfinClient | ||||
|         if not self.jellyfin_user_id or not self.jellyfin_token: | ||||
|             return False | ||||
|         try: | ||||
|             client = JellyfinClient() | ||||
|             return client.is_admin(self.jellyfin_user_id, self.jellyfin_token) | ||||
|         except: | ||||
|             # Im Fehlerfall den lokalen Status verwenden | ||||
|             return self.is_admin | ||||
|              | ||||
|     @property  | ||||
|     def is_jellyfin_admin(self): | ||||
|         """Check if user is admin either locally or on Jellyfin server""" | ||||
|         return self.is_admin or self.check_jellyfin_admin() | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("user") | ||||
|         verbose_name_plural = _("users") | ||||
							
								
								
									
										15
									
								
								accounts/templates/accounts/login.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								accounts/templates/accounts/login.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Anmelden</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Anmelden</button> | ||||
|     </form> | ||||
|     <div class="auth-links"> | ||||
|         <p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										12
									
								
								accounts/templates/accounts/password_change.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								accounts/templates/accounts/password_change.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Passwort ändern</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Passwort ändern</button> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										9
									
								
								accounts/templates/accounts/password_change_done.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								accounts/templates/accounts/password_change_done.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Passwort geändert</h2> | ||||
|     <p>Ihr Passwort wurde erfolgreich geändert.</p> | ||||
|     <p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										97
									
								
								accounts/templates/accounts/profile.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								accounts/templates/accounts/profile.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
|  | ||||
| {% block extra_style %} | ||||
| <link rel="stylesheet" href="{% static 'css/profile.css' %}"> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="profile-container"> | ||||
|     <h2>Hallo, {{ user.username }}</h2> | ||||
|  | ||||
|     {% if messages %} | ||||
|     <div class="messages"> | ||||
|         {% for message in messages %} | ||||
|         <div class="message {{ message.tags }}">{{ message }}</div> | ||||
|         {% endfor %} | ||||
|     </div> | ||||
|     {% endif %} | ||||
|  | ||||
|     <div class="profile-section"> | ||||
|         <h3>E-Mail-Adresse</h3> | ||||
|         <form method="post" class="profile-form compact-form"> | ||||
|             {% csrf_token %} | ||||
|             <div class="form-row"> | ||||
|                 <label for="id_email">E-Mail</label> | ||||
|                 {{ form.email }} | ||||
|             </div> | ||||
|             <button type="submit" class="btn-primary">Speichern</button> | ||||
|         </form> | ||||
|  | ||||
|         {% if user.jellyfin_server %} | ||||
|         <div class="jellyfin-info"> | ||||
|             <h4>Jellyfin-Verbindung</h4> | ||||
|             <p> | ||||
|                 Server: {{ user.jellyfin_server }}<br> | ||||
|                 Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br> | ||||
|                 {% if user.is_jellyfin_admin %} | ||||
|                 <span class="badge badge-admin">Jellyfin Administrator</span> | ||||
|                 {% endif %} | ||||
|             </p> | ||||
|         </div> | ||||
|         {% endif %} | ||||
|     </div> | ||||
|  | ||||
|     <div class="profile-section"> | ||||
|         <h3>Meine Abonnements</h3> | ||||
|  | ||||
|         <h4>Serien</h4> | ||||
|         {% if series_subs %} | ||||
|         <div class="subscription-list"> | ||||
|             {% for sub in series_subs %} | ||||
|             <div class="subscription-item"> | ||||
|                 {% if sub.series_poster %} | ||||
|                 <img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster"> | ||||
|                 {% else %} | ||||
|                 <img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster"> | ||||
|                 {% endif %} | ||||
|                 <div class="subscription-info"> | ||||
|                     <div class="subscription-title">{{ sub.series_title }}</div> | ||||
|                     <div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div> | ||||
|                     {% if sub.series_overview %} | ||||
|                     <div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div> | ||||
|                     {% endif %} | ||||
|                 </div> | ||||
|             </div> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|         {% else %} | ||||
|         <p class="muted">Keine Serien abonniert.</p> | ||||
|         {% endif %} | ||||
|  | ||||
|         <h4>Filme</h4> | ||||
|         {% if movie_subs %} | ||||
|         <div class="subscription-list"> | ||||
|             {% for sub in movie_subs %} | ||||
|             <div class="subscription-item"> | ||||
|                 {% if sub.poster %} | ||||
|                 <img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster"> | ||||
|                 {% else %} | ||||
|                 <img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster"> | ||||
|                 {% endif %} | ||||
|                 <div class="subscription-info"> | ||||
|                     <div class="subscription-title">{{ sub.title }}</div> | ||||
|                     <div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div> | ||||
|                     {% if sub.overview %} | ||||
|                     <div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div> | ||||
|                     {% endif %} | ||||
|                 </div> | ||||
|             </div> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|         {% else %} | ||||
|         <p class="muted">Keine Filme abonniert.</p> | ||||
|         {% endif %} | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										15
									
								
								accounts/templates/accounts/register.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								accounts/templates/accounts/register.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="auth-container"> | ||||
|     <h2>Registrieren</h2> | ||||
|     <form method="post" class="auth-form"> | ||||
|         {% csrf_token %} | ||||
|         {{ form.as_p }} | ||||
|         <button type="submit" class="btn-primary">Registrieren</button> | ||||
|     </form> | ||||
|     <div class="auth-links"> | ||||
|         <p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										3
									
								
								accounts/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								accounts/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.test import TestCase | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										19
									
								
								accounts/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								accounts/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| from django.urls import path | ||||
| from django.contrib.auth import views as auth_views | ||||
| from . import views | ||||
|  | ||||
| app_name = 'accounts' | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path('login/', views.jellyfin_login, name='login'), | ||||
|     path('logout/', auth_views.LogoutView.as_view(), name='logout'), | ||||
|     path('register/', views.RegisterView.as_view(), name='register'), | ||||
|     path('profile/', views.profile, name='profile'), | ||||
|     path('password_change/', auth_views.PasswordChangeView.as_view( | ||||
|         template_name='accounts/password_change.html', | ||||
|         success_url='done/' | ||||
|     ), name='password_change'), | ||||
|     path('password_change/done/', auth_views.PasswordChangeDoneView.as_view( | ||||
|         template_name='accounts/password_change_done.html' | ||||
|     ), name='password_change_done'), | ||||
| ] | ||||
							
								
								
									
										117
									
								
								accounts/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								accounts/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| import requests | ||||
| from django.conf import settings | ||||
| from django.core.cache import cache | ||||
| from functools import wraps | ||||
| from django.shortcuts import redirect | ||||
| from django.contrib import messages | ||||
|  | ||||
| class JellyfinClient: | ||||
|     def __init__(self): | ||||
|         # Basis-Einstellungen aus den Django-Settings | ||||
|         self.client = settings.JELLYFIN_CLIENT | ||||
|         self.version = settings.JELLYFIN_VERSION | ||||
|         self.device = settings.JELLYFIN_DEVICE | ||||
|         self.device_id = settings.JELLYFIN_DEVICE_ID | ||||
|         self.server_url = None  # Wird später gesetzt | ||||
|         self.api_key = None     # Optional, wird aus den AppSettings geholt wenn nötig | ||||
|  | ||||
|     def authenticate(self, username, password): | ||||
|         """Authenticate with Jellyfin and return user info if successful""" | ||||
|         if not self.server_url: | ||||
|             raise ValueError("Keine Server-URL angegeben") | ||||
|  | ||||
|         # Stelle sicher, dass die URL ein Protokoll hat | ||||
|         if not self.server_url.startswith(('http://', 'https://')): | ||||
|             self.server_url = f'http://{self.server_url}' | ||||
|          | ||||
|         # Entferne trailing slashes | ||||
|         self.server_url = self.server_url.rstrip('/') | ||||
|  | ||||
|         headers = { | ||||
|             'X-Emby-Authorization': ( | ||||
|                 f'MediaBrowser Client="{self.client}", ' | ||||
|                 f'Device="{self.device}", ' | ||||
|                 f'DeviceId="{self.device_id}", ' | ||||
|                 f'Version="{self.version}"' | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         auth_data = { | ||||
|             'Username': username, | ||||
|             'Pw': password | ||||
|         } | ||||
|  | ||||
|         try: | ||||
|             response = requests.post( | ||||
|                 f'{self.server_url}/Users/AuthenticateByName', | ||||
|                 json=auth_data, | ||||
|                 headers=headers, | ||||
|                 timeout=10 | ||||
|             ) | ||||
|             response.raise_for_status() | ||||
|             data = response.json() | ||||
|              | ||||
|             return { | ||||
|                 'user_id': data['User']['Id'], | ||||
|                 'access_token': data['AccessToken'], | ||||
|                 'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False) | ||||
|             } | ||||
|         except requests.exceptions.ConnectionError: | ||||
|             raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.") | ||||
|         except requests.exceptions.Timeout: | ||||
|             raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.") | ||||
|         except requests.exceptions.HTTPError as e: | ||||
|             if e.response.status_code == 401: | ||||
|                 return None  # Authentifizierung fehlgeschlagen | ||||
|             raise ValueError(f"HTTP-Fehler: {e.response.status_code}") | ||||
|         except Exception as e: | ||||
|             return None | ||||
|  | ||||
|     def is_admin(self, user_id, token): | ||||
|         """Check if user is admin in Jellyfin""" | ||||
|         cache_key = f'jellyfin_admin_{user_id}' | ||||
|          | ||||
|         # Check cache first | ||||
|         cached = cache.get(cache_key) | ||||
|         if cached is not None: | ||||
|             return cached | ||||
|  | ||||
|         headers = { | ||||
|             'X-Emby-Authorization': ( | ||||
|                 f'MediaBrowser Client="{self.client}", ' | ||||
|                 f'Device="{self.device}", ' | ||||
|                 f'DeviceId="{self.device_id}", ' | ||||
|                 f'Version="{self.version}", ' | ||||
|                 f'Token="{token}"' | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         try: | ||||
|             response = requests.get( | ||||
|                 f'{self.server_url}/Users/{user_id}', | ||||
|                 headers=headers | ||||
|             ) | ||||
|             response.raise_for_status() | ||||
|             data = response.json() | ||||
|             is_admin = data.get('Policy', {}).get('IsAdministrator', False) | ||||
|              | ||||
|             # Cache result for 5 minutes | ||||
|             cache.set(cache_key, is_admin, 300) | ||||
|              | ||||
|             return is_admin | ||||
|         except: | ||||
|             return False | ||||
|  | ||||
| def jellyfin_admin_required(view_func): | ||||
|     @wraps(view_func) | ||||
|     def _wrapped_view(request, *args, **kwargs): | ||||
|         if not request.user.is_authenticated: | ||||
|             messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.') | ||||
|             return redirect('accounts:login') | ||||
|          | ||||
|         if not request.user.is_jellyfin_admin: | ||||
|             messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.') | ||||
|             return redirect('index') | ||||
|              | ||||
|         return view_func(request, *args, **kwargs) | ||||
|     return _wrapped_view | ||||
							
								
								
									
										132
									
								
								accounts/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								accounts/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| from django.shortcuts import render, redirect | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.contrib import messages | ||||
| from django.urls import reverse_lazy | ||||
| from django.views.generic.edit import CreateView | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.contrib.auth import login | ||||
| from django.conf import settings | ||||
| from .forms import CustomUserCreationForm, CustomUserChangeForm, JellyfinLoginForm | ||||
| from .models import User | ||||
| from .utils import JellyfinClient | ||||
|  | ||||
| class RegisterView(CreateView): | ||||
|     form_class = CustomUserCreationForm | ||||
|     template_name = 'accounts/register.html' | ||||
|     success_url = reverse_lazy('accounts:login') | ||||
|      | ||||
|     def form_valid(self, form): | ||||
|         response = super().form_valid(form) | ||||
|         messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.') | ||||
|         return response | ||||
|  | ||||
| @login_required | ||||
| def profile(request): | ||||
|     if request.method == 'POST': | ||||
|         form = CustomUserChangeForm(request.POST, instance=request.user) | ||||
|         if form.is_valid(): | ||||
|             form.save() | ||||
|             messages.success(request, 'E-Mail gespeichert.') | ||||
|             return redirect('accounts:profile') | ||||
|     else: | ||||
|         form = CustomUserChangeForm(instance=request.user) | ||||
|  | ||||
|     # Lade Abonnements | ||||
|     series_subs = request.user.series_subscriptions.all() | ||||
|     movie_subs = request.user.movie_subscriptions.all() | ||||
|  | ||||
|     # Best-effort Backfill fehlender Poster, damit die Profilseite Bilder zeigt | ||||
|     try: | ||||
|         from settingspanel.models import AppSettings | ||||
|         from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title | ||||
|         cfg = AppSettings.current() | ||||
|         # Serien | ||||
|         for sub in series_subs: | ||||
|             if not sub.series_poster and sub.series_id: | ||||
|                 details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key) | ||||
|                 if details and details.get('series_poster'): | ||||
|                     sub.series_poster = details['series_poster'] | ||||
|                     if not sub.series_overview: | ||||
|                         sub.series_overview = details.get('series_overview') or '' | ||||
|                     if not sub.series_genres: | ||||
|                         sub.series_genres = details.get('series_genres') or [] | ||||
|                     sub.save(update_fields=['series_poster', 'series_overview', 'series_genres']) | ||||
|         # Filme | ||||
|         for sub in movie_subs: | ||||
|             if not sub.poster: | ||||
|                 details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key) | ||||
|                 if details and details.get('poster'): | ||||
|                     sub.poster = details['poster'] | ||||
|                     if not sub.overview: | ||||
|                         sub.overview = details.get('overview') or '' | ||||
|                     if not sub.genres: | ||||
|                         sub.genres = details.get('genres') or [] | ||||
|                     sub.save(update_fields=['poster', 'overview', 'genres']) | ||||
|     except Exception: | ||||
|         # still show page even if lookups fail | ||||
|         pass | ||||
|      | ||||
|     return render(request, 'accounts/profile.html', { | ||||
|         'form': form, | ||||
|         'series_subs': series_subs, | ||||
|         'movie_subs': movie_subs, | ||||
|     }) | ||||
|  | ||||
| def jellyfin_login(request): | ||||
|     if request.method == 'POST': | ||||
|         form = JellyfinLoginForm(request.POST) | ||||
|         if form.is_valid(): | ||||
|             username = form.cleaned_data['username'] | ||||
|             password = form.cleaned_data['password'] | ||||
|              | ||||
|             # Jellyfin-URL aus AppSettings | ||||
|             from settingspanel.models import AppSettings | ||||
|             app_settings = AppSettings.current() | ||||
|             server_url = app_settings.get_jellyfin_url() | ||||
|             if not server_url: | ||||
|                 messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.') | ||||
|                 return render(request, 'accounts/login.html', {'form': form}) | ||||
|  | ||||
|             try: | ||||
|                 client = JellyfinClient() | ||||
|                 client.server_url = server_url | ||||
|                 auth_result = client.authenticate(username, password) | ||||
|                  | ||||
|                 if not auth_result: | ||||
|                     messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.') | ||||
|                     return render(request, 'accounts/login.html', {'form': form}) | ||||
|  | ||||
|                 # Existierenden User finden oder neu erstellen | ||||
|                 try: | ||||
|                     user = User.objects.get(username=username) | ||||
|                 except User.DoesNotExist: | ||||
|                     user = User.objects.create_user( | ||||
|                         username=username, | ||||
|                         email=f"{username}@jellyfin.local" | ||||
|                     ) | ||||
|  | ||||
|                 # Jellyfin Daten aktualisieren | ||||
|                 user.jellyfin_user_id = auth_result['user_id'] | ||||
|                 user.jellyfin_token = auth_result['access_token'] | ||||
|                 user.jellyfin_server = server_url | ||||
|                 user.save() | ||||
|  | ||||
|                 if auth_result['is_admin']: | ||||
|                     user.is_admin = True | ||||
|                     user.save() | ||||
|  | ||||
|                 login(request, user) | ||||
|                 messages.success(request, f'Willkommen, {username}!') | ||||
|                 return redirect('arr_api:index') | ||||
|                      | ||||
|             except ValueError as e: | ||||
|                 messages.error(request, str(e)) | ||||
|             except Exception as e: | ||||
|                 messages.error(request, f'Verbindungsfehler: {str(e)}') | ||||
|         # invalid form or error path | ||||
|         return render(request, 'accounts/login.html', {'form': form}) | ||||
|  | ||||
|     else: | ||||
|         form = JellyfinLoginForm() | ||||
|  | ||||
|     return render(request, 'accounts/login.html', {'form': form}) | ||||
							
								
								
									
										14
									
								
								arr_api/management/commands/check_new_media.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								arr_api/management/commands/check_new_media.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils import timezone | ||||
| from arr_api.notifications import check_and_notify_users | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = 'Prüft neue Medien und sendet Benachrichtigungen' | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...') | ||||
|         try: | ||||
|             check_and_notify_users() | ||||
|             self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet')) | ||||
|         except Exception as e: | ||||
|             self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}')) | ||||
							
								
								
									
										52
									
								
								arr_api/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								arr_api/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 11:59 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='MovieSubscription', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('movie_id', models.IntegerField()), | ||||
|                 ('title', models.CharField(max_length=255)), | ||||
|                 ('poster', models.URLField(blank=True, null=True)), | ||||
|                 ('overview', models.TextField(blank=True)), | ||||
|                 ('genres', models.JSONField(default=list)), | ||||
|                 ('release_date', models.DateTimeField(null=True)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('updated_at', models.DateTimeField(auto_now=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_subscriptions', to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'unique_together': {('user', 'movie_id')}, | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='SeriesSubscription', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('series_id', models.IntegerField()), | ||||
|                 ('series_title', models.CharField(max_length=255)), | ||||
|                 ('series_poster', models.URLField(blank=True, null=True)), | ||||
|                 ('series_overview', models.TextField(blank=True)), | ||||
|                 ('series_genres', models.JSONField(default=list)), | ||||
|                 ('created_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('updated_at', models.DateTimeField(auto_now=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_subscriptions', to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'unique_together': {('user', 'series_id')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										32
									
								
								arr_api/migrations/0002_sentnotification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								arr_api/migrations/0002_sentnotification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 14:44 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('arr_api', '0001_initial'), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='SentNotification', | ||||
|             fields=[ | ||||
|                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('media_id', models.IntegerField()), | ||||
|                 ('media_type', models.CharField(max_length=10)), | ||||
|                 ('media_title', models.CharField(max_length=255)), | ||||
|                 ('air_date', models.DateField()), | ||||
|                 ('sent_at', models.DateTimeField(auto_now_add=True)), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'ordering': ['-sent_at'], | ||||
|                 'unique_together': {('user', 'media_id', 'media_type', 'air_date')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,3 +1,53 @@ | ||||
| from django.db import models | ||||
| from django.conf import settings | ||||
|  | ||||
| # Create your models here. | ||||
| class SeriesSubscription(models.Model): | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='series_subscriptions') | ||||
|     series_id = models.IntegerField() | ||||
|     series_title = models.CharField(max_length=255) | ||||
|     series_poster = models.URLField(null=True, blank=True) | ||||
|     series_overview = models.TextField(blank=True) | ||||
|     series_genres = models.JSONField(default=list) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'series_id']  # Ein User kann eine Serie nur einmal abonnieren | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.series_title | ||||
|  | ||||
| class MovieSubscription(models.Model): | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='movie_subscriptions') | ||||
|     movie_id = models.IntegerField() | ||||
|     title = models.CharField(max_length=255) | ||||
|     poster = models.URLField(null=True, blank=True) | ||||
|     overview = models.TextField(blank=True) | ||||
|     genres = models.JSONField(default=list) | ||||
|     release_date = models.DateTimeField(null=True) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|     updated_at = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'movie_id']  # Ein User kann einen Film nur einmal abonnieren | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.title | ||||
|  | ||||
| class SentNotification(models.Model): | ||||
|     """ | ||||
|     Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden | ||||
|     """ | ||||
|     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) | ||||
|     media_id = models.IntegerField() | ||||
|     media_type = models.CharField(max_length=10)  # 'series' oder 'movie' | ||||
|     media_title = models.CharField(max_length=255) | ||||
|     air_date = models.DateField() | ||||
|     sent_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ['user', 'media_id', 'media_type', 'air_date'] | ||||
|         ordering = ['-sent_at'] | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"{self.media_type}: {self.media_title} for {self.user.username} on {self.air_date}" | ||||
|   | ||||
							
								
								
									
										356
									
								
								arr_api/notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								arr_api/notifications.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,356 @@ | ||||
| from django.core.mail import send_mail | ||||
| from django.conf import settings | ||||
| from django.template.loader import render_to_string | ||||
| from django.utils import timezone | ||||
| from settingspanel.models import AppSettings | ||||
| # from accounts.utils import JellyfinClient  # not needed for availability; use Sonarr/Radarr instead | ||||
| import requests | ||||
| from dateutil.parser import isoparse | ||||
| import logging | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| def _set_runtime_email_settings(): | ||||
|     app_settings = AppSettings.current() | ||||
|     sec = (app_settings.mail_secure or '').strip().lower() | ||||
|     use_tls = sec in ('tls', 'starttls', 'start_tls', 'tls1.2', 'tls1_2') | ||||
|     use_ssl = sec in ('ssl', 'smtps') | ||||
|     # Prefer SSL over TLS if both matched somehow | ||||
|     if use_ssl: | ||||
|         use_tls = False | ||||
|  | ||||
|     # Apply email settings dynamically for this process | ||||
|     settings.EMAIL_HOST = (app_settings.mail_host or settings.EMAIL_HOST) | ||||
|     # Port defaults if not provided | ||||
|     if app_settings.mail_port: | ||||
|         settings.EMAIL_PORT = int(app_settings.mail_port) | ||||
|     else: | ||||
|         if use_ssl and not settings.EMAIL_PORT: | ||||
|             settings.EMAIL_PORT = 465 | ||||
|         elif use_tls and not settings.EMAIL_PORT: | ||||
|             settings.EMAIL_PORT = 587 | ||||
|  | ||||
|     settings.EMAIL_USE_TLS = use_tls | ||||
|     settings.EMAIL_USE_SSL = use_ssl | ||||
|  | ||||
|     settings.EMAIL_HOST_USER = app_settings.mail_user or settings.EMAIL_HOST_USER | ||||
|     settings.EMAIL_HOST_PASSWORD = app_settings.mail_password or settings.EMAIL_HOST_PASSWORD | ||||
|  | ||||
|     # From email fallback | ||||
|     if app_settings.mail_from: | ||||
|         settings.DEFAULT_FROM_EMAIL = app_settings.mail_from | ||||
|     elif not getattr(settings, 'DEFAULT_FROM_EMAIL', None): | ||||
|         host = (settings.EMAIL_HOST or 'localhost') | ||||
|         settings.DEFAULT_FROM_EMAIL = f'noreply@{host}' | ||||
|  | ||||
|     # return summary for debugging | ||||
|     return { | ||||
|         'host': settings.EMAIL_HOST, | ||||
|         'port': settings.EMAIL_PORT, | ||||
|         'use_tls': settings.EMAIL_USE_TLS, | ||||
|         'use_ssl': settings.EMAIL_USE_SSL, | ||||
|         'from_email': settings.DEFAULT_FROM_EMAIL, | ||||
|         'auth_user_set': bool(settings.EMAIL_HOST_USER), | ||||
|     } | ||||
|  | ||||
|  | ||||
| def send_notification_email( | ||||
|     user, | ||||
|     media_title, | ||||
|     media_type, | ||||
|     overview=None, | ||||
|     poster_url=None, | ||||
|     episode_title=None, | ||||
|     season=None, | ||||
|     episode=None, | ||||
|     air_date=None, | ||||
|     year=None, | ||||
|     release_type=None, | ||||
| ): | ||||
|     """ | ||||
|     Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details | ||||
|     """ | ||||
|     eff = _set_runtime_email_settings() | ||||
|     logger.info( | ||||
|         "Email settings: host=%s port=%s tls=%s ssl=%s from=%s auth_user_set=%s", | ||||
|         eff['host'], eff['port'], eff['use_tls'], eff['use_ssl'], eff['from_email'], eff['auth_user_set'] | ||||
|     ) | ||||
|  | ||||
|     # Format air date if provided | ||||
|     air_date_str = None | ||||
|     if air_date: | ||||
|         try: | ||||
|             from dateutil.parser import isoparse as _iso | ||||
|             dt = _iso(air_date) if isinstance(air_date, str) else air_date | ||||
|             try: | ||||
|                 tz = timezone.get_current_timezone() | ||||
|                 dt = dt.astimezone(tz) | ||||
|             except Exception: | ||||
|                 pass | ||||
|             air_date_str = dt.strftime('%d.%m.%Y %H:%M') | ||||
|         except Exception: | ||||
|             air_date_str = str(air_date) | ||||
|  | ||||
|     context = { | ||||
|         'username': user.username, | ||||
|         'title': media_title, | ||||
|         'type': 'Serie' if media_type == 'series' else 'Film', | ||||
|         'overview': overview, | ||||
|         'poster_url': poster_url, | ||||
|         'episode_title': episode_title, | ||||
|         'season': season, | ||||
|         'episode': episode, | ||||
|         'air_date': air_date_str, | ||||
|         'year': year, | ||||
|         'release_type': release_type, | ||||
|     } | ||||
|  | ||||
|     subject = f"Neue {context['type']} verfügbar: {media_title}" | ||||
|     message = render_to_string('arr_api/email/new_media_notification.html', context) | ||||
|  | ||||
|     send_mail( | ||||
|         subject=subject, | ||||
|         message=message, | ||||
|         from_email=settings.DEFAULT_FROM_EMAIL, | ||||
|         recipient_list=[user.email], | ||||
|         html_message=message, | ||||
|         fail_silently=False, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def _get_arr_cfg(): | ||||
|     cfg = AppSettings.current() | ||||
|     return { | ||||
|         'sonarr_url': (cfg.sonarr_url or '').strip(), | ||||
|         'sonarr_key': (cfg.sonarr_api_key or '').strip(), | ||||
|         'radarr_url': (cfg.radarr_url or '').strip(), | ||||
|         'radarr_key': (cfg.radarr_api_key or '').strip(), | ||||
|     } | ||||
|  | ||||
|  | ||||
| def _sonarr_get(url_base, api_key, path, params=None, timeout=10): | ||||
|     if not url_base or not api_key: | ||||
|         return None | ||||
|     url = f"{url_base.rstrip('/')}{path}" | ||||
|     try: | ||||
|         r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout) | ||||
|         r.raise_for_status() | ||||
|         return r.json() | ||||
|     except requests.RequestException: | ||||
|         return None | ||||
|  | ||||
|  | ||||
| def _radarr_get(url_base, api_key, path, params=None, timeout=10): | ||||
|     if not url_base or not api_key: | ||||
|         return None | ||||
|     url = f"{url_base.rstrip('/')}{path}" | ||||
|     try: | ||||
|         r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout) | ||||
|         r.raise_for_status() | ||||
|         return r.json() | ||||
|     except requests.RequestException: | ||||
|         return None | ||||
|  | ||||
|  | ||||
| def sonarr_episode_has_file(series_id: int, season: int, episode: int) -> bool: | ||||
|     cfg = _get_arr_cfg() | ||||
|     data = _sonarr_get(cfg['sonarr_url'], cfg['sonarr_key'], "/api/v3/episode", params={"seriesId": series_id}) or [] | ||||
|     for ep in data: | ||||
|         if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: | ||||
|             return bool(ep.get("hasFile")) | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def radarr_movie_has_file(movie_id: int) -> bool: | ||||
|     cfg = _get_arr_cfg() | ||||
|     data = _radarr_get(cfg['radarr_url'], cfg['radarr_key'], f"/api/v3/movie/{movie_id}") | ||||
|     if not data: | ||||
|         return False | ||||
|     return bool(data.get("hasFile")) | ||||
|  | ||||
|  | ||||
| def get_todays_sonarr_calendar(): | ||||
|     from .services import sonarr_calendar | ||||
|     cfg = _get_arr_cfg() | ||||
|     items = sonarr_calendar(days=1, base_url=cfg['sonarr_url'], api_key=cfg['sonarr_key']) or [] | ||||
|     today = timezone.now().date() | ||||
|     todays = [] | ||||
|     for it in items: | ||||
|         try: | ||||
|             ad = isoparse(it.get("airDateUtc")) if it.get("airDateUtc") else None | ||||
|             if ad and ad.date() == today: | ||||
|                 todays.append(it) | ||||
|         except Exception: | ||||
|             pass | ||||
|     return todays | ||||
|  | ||||
|  | ||||
| def get_todays_radarr_calendar(): | ||||
|     from .services import radarr_calendar | ||||
|     cfg = _get_arr_cfg() | ||||
|     items = radarr_calendar(days=1, base_url=cfg['radarr_url'], api_key=cfg['radarr_key']) or [] | ||||
|     today = timezone.now().date() | ||||
|     todays = [] | ||||
|     for it in items: | ||||
|         # consider any of the dates equal today | ||||
|         for k in ("inCinemas", "physicalRelease", "digitalRelease"): | ||||
|             v = it.get(k) | ||||
|             if not v: | ||||
|                 continue | ||||
|             try: | ||||
|                 d = isoparse(v).date() | ||||
|                 if d == today: | ||||
|                     todays.append(it) | ||||
|                     break | ||||
|             except Exception: | ||||
|                 continue | ||||
|     return todays | ||||
|  | ||||
|  | ||||
| def check_jellyfin_availability(user, media_id, media_type): | ||||
|     """ | ||||
|     Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile), | ||||
|     was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt. | ||||
|     """ | ||||
|     # user is unused here; kept for backward compatibility | ||||
|     if media_type == 'series': | ||||
|         # cannot decide without season/episode here; will be handled in main loop | ||||
|         return False | ||||
|     else: | ||||
|         return radarr_movie_has_file(media_id) | ||||
|  | ||||
|  | ||||
| def check_and_notify_users(): | ||||
|     """ | ||||
|     Hauptfunktion die periodisch aufgerufen wird. | ||||
|     Prüft neue Medien und sendet Benachrichtigungen. | ||||
|     """ | ||||
|     from .models import SeriesSubscription, MovieSubscription, SentNotification | ||||
|  | ||||
|     # calendars for today | ||||
|     todays_series = get_todays_sonarr_calendar() | ||||
|     todays_movies = get_todays_radarr_calendar() | ||||
|  | ||||
|     # index by ids for quick lookup | ||||
|     series_idx = {} | ||||
|     for it in todays_series: | ||||
|         sid = it.get("seriesId") | ||||
|         if not sid: | ||||
|             continue | ||||
|         series_idx.setdefault(sid, []).append(it) | ||||
|  | ||||
|     movie_idx = {it.get("movieId"): it for it in todays_movies if it.get("movieId")} | ||||
|  | ||||
|     today = timezone.now().date() | ||||
|  | ||||
|     # Serien-Abos | ||||
|     for sub in SeriesSubscription.objects.select_related('user').all(): | ||||
|         if sub.series_id not in series_idx: | ||||
|             continue | ||||
|         # iterate today's episodes for this series | ||||
|         for ep in series_idx[sub.series_id]: | ||||
|             season = ep.get("seasonNumber") | ||||
|             number = ep.get("episodeNumber") | ||||
|             if season is None or number is None: | ||||
|                 continue | ||||
|  | ||||
|             # duplicate guard (per series per day per user) | ||||
|             if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||
|                 already_notified = SentNotification.objects.filter( | ||||
|                     media_id=sub.series_id, | ||||
|                     media_type='series', | ||||
|                     air_date=today, | ||||
|                     user=sub.user | ||||
|                 ).exists() | ||||
|                 if already_notified: | ||||
|                     continue | ||||
|  | ||||
|             # check availability via Sonarr hasFile | ||||
|             if sonarr_episode_has_file(sub.series_id, season, number): | ||||
|                 if not sub.user.email: | ||||
|                     continue | ||||
|                 send_notification_email( | ||||
|                     user=sub.user, | ||||
|                     media_title=sub.series_title, | ||||
|                     media_type='series', | ||||
|                     overview=sub.series_overview, | ||||
|                     poster_url=ep.get('seriesPoster'), | ||||
|                     episode_title=ep.get('title'), | ||||
|                     season=season, | ||||
|                     episode=number, | ||||
|                     air_date=ep.get('airDateUtc'), | ||||
|                 ) | ||||
|                 # mark as sent unless duplicates are allowed | ||||
|                 if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||
|                     SentNotification.objects.create( | ||||
|                         user=sub.user, | ||||
|                         media_id=sub.series_id, | ||||
|                         media_type='series', | ||||
|                         media_title=sub.series_title, | ||||
|                         air_date=today | ||||
|                     ) | ||||
|  | ||||
|     # Film-Abos | ||||
|     for sub in MovieSubscription.objects.select_related('user').all(): | ||||
|         it = movie_idx.get(sub.movie_id) | ||||
|         if not it: | ||||
|             continue | ||||
|  | ||||
|         if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||
|             already_notified = SentNotification.objects.filter( | ||||
|                 media_id=sub.movie_id, | ||||
|                 media_type='movie', | ||||
|                 air_date=today, | ||||
|                 user=sub.user | ||||
|             ).exists() | ||||
|             if already_notified: | ||||
|                 continue | ||||
|  | ||||
|         if radarr_movie_has_file(sub.movie_id): | ||||
|             if not sub.user.email: | ||||
|                 continue | ||||
|             # detect which release matched today | ||||
|             rel = None | ||||
|             try: | ||||
|                 for key, name in (("digitalRelease", "Digital"), ("physicalRelease", "Disc"), ("inCinemas", "Kino")): | ||||
|                     v = it.get(key) | ||||
|                     if not v: | ||||
|                         continue | ||||
|                     d = isoparse(v).date() | ||||
|                     if d == today: | ||||
|                         rel = name | ||||
|                         break | ||||
|             except Exception: | ||||
|                 pass | ||||
|  | ||||
|             send_notification_email( | ||||
|                 user=sub.user, | ||||
|                 media_title=sub.title, | ||||
|                 media_type='movie', | ||||
|                 overview=sub.overview, | ||||
|                 poster_url=it.get('posterUrl'), | ||||
|                 year=it.get('year'), | ||||
|                 release_type=rel, | ||||
|             ) | ||||
|             if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||
|                 SentNotification.objects.create( | ||||
|                     user=sub.user, | ||||
|                     media_id=sub.movie_id, | ||||
|                     media_type='movie', | ||||
|                     media_title=sub.title, | ||||
|                     air_date=today | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| def has_new_episode_today(series_id): | ||||
|     """ | ||||
|     Legacy helper no longer used directly. | ||||
|     """ | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def has_movie_release_today(movie_id): | ||||
|     """ | ||||
|     Legacy helper no longer used directly. | ||||
|     """ | ||||
|     return True | ||||
| @@ -25,7 +25,7 @@ def _get(url, headers, params=None, timeout=5): | ||||
| 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 | ||||
|     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() | ||||
| @@ -131,3 +131,62 @@ def radarr_calendar(days: int | None = None, base_url: str | None = None, api_ke | ||||
|         return False | ||||
|  | ||||
|     return [m for m in out if is_upcoming(m)] | ||||
|  | ||||
| def sonarr_get_series(series_id: int, base_url: str | None = None, api_key: str | None = None) -> dict | None: | ||||
|     """Fetch a single series by id from Sonarr, return dict with title, overview, poster and genres.""" | ||||
|     base = (base_url or ENV_SONARR_URL).strip() | ||||
|     key  = (api_key  or ENV_SONARR_KEY).strip() | ||||
|     if not base or not key: | ||||
|         return None | ||||
|     url = f"{base.rstrip('/')}/api/v3/series/{series_id}" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers) | ||||
|     # Poster | ||||
|     poster = None | ||||
|     for img in (data.get("images") or []): | ||||
|         if (img.get("coverType") or "").lower() == "poster": | ||||
|             poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|             if poster: | ||||
|                 break | ||||
|     return { | ||||
|         "series_id": data.get("id"), | ||||
|         "series_title": data.get("title"), | ||||
|         "series_overview": data.get("overview") or "", | ||||
|         "series_genres": data.get("genres") or [], | ||||
|         "series_poster": poster, | ||||
|     } | ||||
|  | ||||
| def radarr_lookup_movie_by_title(title: str, base_url: str | None = None, api_key: str | None = None) -> dict | None: | ||||
|     """Lookup a movie by title via Radarr /api/v3/movie/lookup. Returns title, poster, overview, genres, year, tmdbId, and id if present.""" | ||||
|     base = (base_url or ENV_RADARR_URL).strip() | ||||
|     key  = (api_key  or ENV_RADARR_KEY).strip() | ||||
|     if not base or not key or not title: | ||||
|         return None | ||||
|     url = f"{base.rstrip('/')}/api/v3/movie/lookup" | ||||
|     headers = {"X-Api-Key": key} | ||||
|     data = _get(url, headers, params={"term": title}) | ||||
|     if not data: | ||||
|         return None | ||||
|     # naive pick: exact match by title (case-insensitive), else first | ||||
|     best = None | ||||
|     for it in data: | ||||
|         if (it.get("title") or "").lower() == title.lower(): | ||||
|             best = it | ||||
|             break | ||||
|     if not best: | ||||
|         best = data[0] | ||||
|     poster = None | ||||
|     for img in (best.get("images") or []): | ||||
|         if (img.get("coverType") or "").lower() == "poster": | ||||
|             poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) | ||||
|             if poster: | ||||
|                 break | ||||
|     return { | ||||
|         "movie_id": best.get("id") or 0, | ||||
|         "title": best.get("title") or title, | ||||
|         "poster": poster, | ||||
|         "overview": best.get("overview") or "", | ||||
|         "genres": best.get("genres") or [], | ||||
|         "year": best.get("year"), | ||||
|         "tmdbId": best.get("tmdbId"), | ||||
|     } | ||||
|   | ||||
							
								
								
									
										111
									
								
								arr_api/templates/arr_api/email/new_media_notification.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								arr_api/templates/arr_api/email/new_media_notification.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <style> | ||||
|         body { | ||||
|             font-family: Arial, sans-serif; | ||||
|             line-height: 1.6; | ||||
|             color: #333; | ||||
|             max-width: 700px; | ||||
|             margin: 0 auto; | ||||
|             padding: 20px; | ||||
|         } | ||||
|  | ||||
|         .header { | ||||
|             border-bottom: 2px solid #3b82f6; | ||||
|             padding-bottom: 10px; | ||||
|             margin-bottom: 20px; | ||||
|         } | ||||
|  | ||||
|         .content { | ||||
|             background: #f8fafc; | ||||
|             padding: 20px; | ||||
|             border-radius: 8px; | ||||
|             display: grid; | ||||
|             grid-template-columns: 140px 1fr; | ||||
|             gap: 16px; | ||||
|             align-items: start; | ||||
|         } | ||||
|  | ||||
|         .title { | ||||
|             color: #1e40af; | ||||
|             font-size: 22px; | ||||
|             margin: 0 0 8px 0; | ||||
|         } | ||||
|  | ||||
|         .overview { | ||||
|             color: #444; | ||||
|             margin: 10px 0; | ||||
|         } | ||||
|  | ||||
|         .meta { | ||||
|             color: #555; | ||||
|             font-size: 14px; | ||||
|             margin: 6px 0; | ||||
|         } | ||||
|  | ||||
|         .poster { | ||||
|             width: 140px; | ||||
|             border-radius: 6px; | ||||
|             background: #e5e7eb; | ||||
|             object-fit: cover; | ||||
|         } | ||||
|  | ||||
|         .kbd { | ||||
|             display: inline-block; | ||||
|             padding: 2px 6px; | ||||
|             border: 1px solid #d1d5db; | ||||
|             border-bottom-width: 2px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             background: #fff; | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <div class="header"> | ||||
|         <h1>Neue {{ type }} verfügbar!</h1> | ||||
|     </div> | ||||
|     <div class="content"> | ||||
|         {% if poster_url %} | ||||
|         <img class="poster" src="{{ poster_url }}" alt="Poster" /> | ||||
|         {% else %} | ||||
|         <div></div> | ||||
|         {% endif %} | ||||
|  | ||||
|         <div> | ||||
|             <p>Hallo {{ username }},</p> | ||||
|  | ||||
|             <h2 class="title">{{ title }}</h2> | ||||
|  | ||||
|             {% if episode_title %} | ||||
|             <p class="meta">Episode: <strong>{{ episode_title }}</strong></p> | ||||
|             {% endif %} | ||||
|             {% if season and episode %} | ||||
|             <p class="meta"><span class="kbd">S{{ season }}E{{ episode }}</span></p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if year %} | ||||
|             <p class="meta">Jahr: <strong>{{ year }}</strong></p> | ||||
|             {% endif %} | ||||
|             {% if release_type %} | ||||
|             <p class="meta">Release: {{ release_type }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if air_date %} | ||||
|             <p class="meta">Veröffentlicht am: {{ air_date }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             {% if overview %} | ||||
|             <p class="overview">{{ overview }}</p> | ||||
|             {% endif %} | ||||
|  | ||||
|             <p>Du kannst das jetzt auf Jellyfin anschauen.</p> | ||||
|         </div> | ||||
|     </div> | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
| @@ -1,442 +1,19 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="de"> | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
|  | ||||
| <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; | ||||
|         } | ||||
| {% block title %}Subscribarr – Übersicht{% endblock %} | ||||
|  | ||||
|         * { | ||||
|             box-sizing: border-box; | ||||
|         } | ||||
| {% block extra_style %} | ||||
| <link rel="stylesheet" href="{% static 'css/index.css' %}"> | ||||
| {% endblock %} | ||||
|  | ||||
|         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;"> | ||||
| {% block content %} | ||||
| <div class="debug-info"> | ||||
|     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> | ||||
|  | ||||
|  | ||||
| {% csrf_token %} | ||||
| <div class="wrap"> | ||||
|     <h1>Subscribarr</h1> | ||||
|  | ||||
| @@ -444,8 +21,7 @@ | ||||
|         <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"> | ||||
|             <input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen"> | ||||
|             <button type="submit">Suchen</button> | ||||
|         </form> | ||||
|  | ||||
| @@ -467,8 +43,7 @@ | ||||
|         <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 }}"> | ||||
|                 data-poster="{{ s.seriesPoster|default:'' }}" data-overview="{{ s.seriesOverview|default:''|escape }}"> | ||||
|                 <div class="poster"> | ||||
|                     {% if s.seriesPoster %} | ||||
|                     <img src="{{ s.seriesPoster }}" alt="{{ s.seriesTitle }}"> | ||||
| @@ -596,21 +171,85 @@ | ||||
|         backdrop.addEventListener("click", e => { if (e.target === backdrop) closeModal(); }); | ||||
|         window.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); }); | ||||
|  | ||||
|             // ===== Subscribe-Only-UI (mit localStorage) ===== | ||||
|         // ===== Subscribe-UI mit Backend-Sync ===== | ||||
|         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 || ""); | ||||
|         } | ||||
|  | ||||
|         // Cache für Abonnement-Status | ||||
|         const subCache = new Map(); | ||||
|  | ||||
|         async function loadAllSubs() { | ||||
|             try { | ||||
|                 const [seriesResp, moviesResp] = await Promise.all([ | ||||
|                     fetch('/api/series/subscriptions/'), | ||||
|                     fetch('/api/movies/subscriptions/') | ||||
|                 ]); | ||||
|  | ||||
|                 if (seriesResp.ok) { | ||||
|                     const seriesSubs = await seriesResp.json(); | ||||
|                     seriesSubs.forEach(id => subCache.set(`series:${id}`, true)); | ||||
|                 } | ||||
|  | ||||
|                 if (moviesResp.ok) { | ||||
|                     const movieSubs = await moviesResp.json(); | ||||
|                     movieSubs.forEach(title => subCache.set(`movie:${title}`, true)); | ||||
|                 } | ||||
|             } catch (err) { | ||||
|                 console.error("Failed to load subscriptions:", err); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         function loadSub(card) { | ||||
|             const k = subKey(card); | ||||
|                 return k ? localStorage.getItem("sub:" + k) === "1" : false; | ||||
|             return k ? subCache.get(k) || false : false; | ||||
|         } | ||||
|             function saveSub(card, on) { | ||||
|  | ||||
|         async function saveSub(card, on) { | ||||
|             const k = subKey(card); | ||||
|             if (!k) return; | ||||
|                 if (on) localStorage.setItem("sub:" + k, "1"); | ||||
|                 else localStorage.removeItem("sub:" + k); | ||||
|             const [type, id] = k.split(":"); | ||||
|  | ||||
|             try { | ||||
|                 const resp = await fetch(`/api/${type}/${on ? 'subscribe' : 'unsubscribe'}/${encodeURIComponent(id)}/`, { | ||||
|                     method: 'POST', | ||||
|                     headers: { | ||||
|                         'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 if (resp.status === 403) { | ||||
|                     // Nicht eingeloggt | ||||
|                     window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | ||||
|  | ||||
|                 // Cache aktualisieren | ||||
|                 if (on) { | ||||
|                     subCache.set(k, true); | ||||
|                 } else { | ||||
|                     subCache.delete(k); | ||||
|                 } | ||||
|             } catch (err) { | ||||
|                 console.error("Failed to update subscription:", err); | ||||
|                 // Cache-Update rückgängig machen bei Fehler | ||||
|                 if (on) { | ||||
|                     subCache.delete(k); | ||||
|                 } else { | ||||
|                     subCache.set(k, true); | ||||
|                 } | ||||
|  | ||||
|                 // Fehlermeldung anzeigen | ||||
|                 const errorMsg = document.createElement('div'); | ||||
|                 errorMsg.className = 'error-message'; | ||||
|                 errorMsg.textContent = 'Fehler beim Aktualisieren des Abonnements. Bitte versuchen Sie es später erneut.'; | ||||
|                 document.body.appendChild(errorMsg); | ||||
|                 setTimeout(() => errorMsg.remove(), 3000); | ||||
|             } | ||||
|         } | ||||
|         function applySubUI(card, on) { | ||||
|             if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS) | ||||
| @@ -620,8 +259,14 @@ | ||||
|             } | ||||
|         } | ||||
|  | ||||
|             // Beim Laden: gespeicherten Zustand auf alle Karten anwenden | ||||
|             $$(".card, .movie-card").forEach(c => applySubUI(c, loadSub(c))); | ||||
|         // Beim Laden: Alle Abonnements in einem API-Call laden | ||||
|         (async () => { | ||||
|             await loadAllSubs(); | ||||
|             const cards = $$(".card, .movie-card"); | ||||
|             cards.forEach(card => { | ||||
|                 applySubUI(card, loadSub(card)); | ||||
|             }); | ||||
|         })(); | ||||
|  | ||||
|         // ===== Serien-Karten öffnen ===== | ||||
|         $$(".card").forEach(card => { | ||||
| @@ -681,6 +326,11 @@ | ||||
|                 // Subscribe-UI für diese Karte setzen | ||||
|                 applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                 // Status nochmal aktualisieren zur Sicherheit | ||||
|                 loadAllSubs().then(() => { | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|                 }); | ||||
|  | ||||
|                 openModal(); | ||||
|             }); | ||||
|         }); | ||||
| @@ -709,17 +359,31 @@ | ||||
|                 // Subscribe-UI für diese Karte setzen | ||||
|                 applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|  | ||||
|                 // Status nochmal aktualisieren zur Sicherheit | ||||
|                 loadAllSubs().then(() => { | ||||
|                     applySubUI(lastClickedCard, loadSub(lastClickedCard)); | ||||
|                 }); | ||||
|  | ||||
|                 openModal(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|             // ===== Subscribe-Button im Modal toggelt nur UI + localStorage ===== | ||||
|         // ===== Subscribe-Button im Modal mit Backend-Sync ===== | ||||
|         if (subscribeBtn) { | ||||
|                 subscribeBtn.addEventListener("click", () => { | ||||
|             subscribeBtn.addEventListener("click", async () => { | ||||
|                 if (!lastClickedCard) return; | ||||
|                     const now = !loadSub(lastClickedCard); | ||||
|                     saveSub(lastClickedCard, now); | ||||
|                     applySubUI(lastClickedCard, now); | ||||
|                 const current = await loadSub(lastClickedCard); | ||||
|                 const newState = !current; | ||||
|  | ||||
|                 // Optimistic UI update | ||||
|                 applySubUI(lastClickedCard, newState); | ||||
|  | ||||
|                 // Backend-Sync | ||||
|                 await saveSub(lastClickedCard, newState); | ||||
|  | ||||
|                 // Status neu laden zur Sicherheit | ||||
|                 const finalState = await loadSub(lastClickedCard); | ||||
|                 applySubUI(lastClickedCard, finalState); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
| @@ -732,7 +396,7 @@ | ||||
|         }); | ||||
|     })(); | ||||
| </script> | ||||
|  | ||||
| {% endblock %} | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
| @@ -1,7 +1,25 @@ | ||||
| from django.urls import path | ||||
| from .views import SonarrAiringView, RadarrUpcomingMoviesView | ||||
| from .views import ( | ||||
|     ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView, | ||||
|     MovieSubscribeView, MovieUnsubscribeView, | ||||
|     ListSeriesSubscriptionsView, ListMovieSubscriptionsView | ||||
| ) | ||||
|  | ||||
| app_name = 'arr_api' | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"), | ||||
|     path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"), | ||||
|     path('', ArrIndexView.as_view(), name='index'), | ||||
|      | ||||
|     # Series URLs | ||||
|     path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'), | ||||
|     path('api/series/unsubscribe/<int:series_id>/', SeriesUnsubscribeView.as_view(), name='unsubscribe-series'), | ||||
|     path('api/series/subscriptions/', ListSeriesSubscriptionsView.as_view(), name='list-series-subscriptions'), | ||||
|      | ||||
|     # Movie URLs | ||||
|     path('api/movies/subscribe/<str:title>/', MovieSubscribeView.as_view(), name='subscribe-movie'), | ||||
|     path('api/movies/unsubscribe/<str:title>/', MovieUnsubscribeView.as_view(), name='unsubscribe-movie'), | ||||
|     path('api/movies/subscriptions/', ListMovieSubscriptionsView.as_view(), name='list-movie-subscriptions'), | ||||
|      | ||||
|     # Get all subscriptions | ||||
|  | ||||
| ] | ||||
							
								
								
									
										303
									
								
								arr_api/views.py
									
									
									
									
									
								
							
							
						
						
									
										303
									
								
								arr_api/views.py
									
									
									
									
									
								
							| @@ -1,13 +1,18 @@ | ||||
| from collections import defaultdict | ||||
| from django.shortcuts import render | ||||
| from django.shortcuts import render, redirect, get_object_or_404 | ||||
| from django.views import View | ||||
| from django.contrib import messages | ||||
| from django.views.decorators.http import require_POST | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.http import JsonResponse | ||||
| 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 | ||||
| from .models import SeriesSubscription, MovieSubscription | ||||
|  | ||||
|  | ||||
| def _get_int(request, key, default): | ||||
| @@ -27,26 +32,26 @@ def _arr_conf_from_db(): | ||||
|     } | ||||
|  | ||||
|  | ||||
| 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 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 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): | ||||
| @@ -75,10 +80,14 @@ class ArrIndexView(View): | ||||
|             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()] | ||||
|  | ||||
|         # Abonnierte Serien und Filme laden | ||||
|         subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True)) | ||||
|         subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True)) | ||||
|  | ||||
|         # Gruppierung nach Serie | ||||
|         groups = defaultdict(lambda: { | ||||
|             "seriesId": None, "seriesTitle": None, "seriesPoster": None, | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], | ||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], "is_subscribed": False, | ||||
|         }) | ||||
|         for e in eps: | ||||
|             sid = e["seriesId"] | ||||
| @@ -101,8 +110,13 @@ class ArrIndexView(View): | ||||
|         series_grouped = [] | ||||
|         for g in groups.values(): | ||||
|             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) | ||||
|             g["is_subscribed"] = g["seriesId"] in subscribed_series_ids | ||||
|             series_grouped.append(g) | ||||
|  | ||||
|         # Markiere abonnierte Filme | ||||
|         for movie in movies: | ||||
|             movie["is_subscribed"] = movie.get("movieId") in subscribed_movie_ids | ||||
|  | ||||
|         return render(request, "arr_api/index.html", { | ||||
|             "query": q, | ||||
|             "kind": kind, | ||||
| @@ -112,3 +126,252 @@ class ArrIndexView(View): | ||||
|             "series_grouped": series_grouped, | ||||
|             "movies": movies, | ||||
|         }) | ||||
|  | ||||
|  | ||||
| class SubscribeSeriesView(View): | ||||
|     @method_decorator(require_POST) | ||||
|     def post(self, request, series_id): | ||||
|         series_data = { | ||||
|             'series_id': series_id, | ||||
|             'series_title': request.POST.get('series_title'), | ||||
|             'series_poster': request.POST.get('series_poster'), | ||||
|             'series_overview': request.POST.get('series_overview'), | ||||
|             'series_genres': request.POST.getlist('series_genres[]', []) | ||||
|         } | ||||
|          | ||||
|         subscription, created = SeriesSubscription.objects.get_or_create( | ||||
|             series_id=series_id, | ||||
|             defaults=series_data | ||||
|         ) | ||||
|          | ||||
|         if created: | ||||
|             messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!') | ||||
|         else: | ||||
|             messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.') | ||||
|              | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
| class UnsubscribeSeriesView(View): | ||||
|     @method_decorator(require_POST) | ||||
|     def post(self, request, series_id): | ||||
|         subscription = get_object_or_404(SeriesSubscription, series_id=series_id) | ||||
|         series_title = subscription.series_title | ||||
|         subscription.delete() | ||||
|         messages.success(request, f'Abonnement für "{series_title}" wurde beendet.') | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
| class SubscribeMovieView(View): | ||||
|     @method_decorator(require_POST) | ||||
|     def post(self, request, movie_id): | ||||
|         movie_data = { | ||||
|             'movie_id': movie_id, | ||||
|             'title': request.POST.get('title'), | ||||
|             'poster': request.POST.get('poster'), | ||||
|             'overview': request.POST.get('overview'), | ||||
|             'genres': request.POST.getlist('genres[]', []), | ||||
|             'release_date': request.POST.get('release_date') | ||||
|         } | ||||
|          | ||||
|         subscription, created = MovieSubscription.objects.get_or_create( | ||||
|             movie_id=movie_id, | ||||
|             defaults=movie_data | ||||
|         ) | ||||
|          | ||||
|         if created: | ||||
|             messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!') | ||||
|         else: | ||||
|             messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.') | ||||
|              | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
| class UnsubscribeMovieView(View): | ||||
|     @method_decorator(require_POST) | ||||
|     def post(self, request, movie_id): | ||||
|         subscription = get_object_or_404(MovieSubscription, movie_id=movie_id) | ||||
|         movie_title = subscription.title | ||||
|         subscription.delete() | ||||
|         messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.') | ||||
|         return redirect('arr_api:index') | ||||
|  | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_series(request, series_id): | ||||
|     """Serie abonnieren""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists(): | ||||
|             return JsonResponse({'success': True, 'already_subscribed': True}) | ||||
|  | ||||
|         # Hole Serien-Details vom Sonarr | ||||
|         conf = _arr_conf_from_db() | ||||
|         # TODO: Sonarr API Call für Series Details | ||||
|  | ||||
|         # Erstelle Subscription | ||||
|         sub = SeriesSubscription.objects.create( | ||||
|             user=request.user, | ||||
|             series_id=series_id, | ||||
|             series_title=request.POST.get('title', ''), | ||||
|             series_poster=request.POST.get('poster', ''), | ||||
|             series_overview=request.POST.get('overview', ''), | ||||
|             series_genres=request.POST.getlist('genres[]', []) | ||||
|         ) | ||||
|         return JsonResponse({'success': True}) | ||||
|  | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_series(request, series_id): | ||||
|     """Serie deabonnieren""" | ||||
|     try: | ||||
|         SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @login_required | ||||
| def is_subscribed_series(request, series_id): | ||||
|     """Prüfe ob Serie abonniert ist""" | ||||
|     is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def subscribe_movie(request, movie_id): | ||||
|     """Film abonnieren""" | ||||
|     try: | ||||
|         # Existiert bereits? | ||||
|         if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists(): | ||||
|             return JsonResponse({'success': True, 'already_subscribed': True}) | ||||
|  | ||||
|         # Hole Film-Details vom Radarr | ||||
|         conf = _arr_conf_from_db() | ||||
|         # TODO: Radarr API Call für Movie Details | ||||
|  | ||||
|         # Erstelle Subscription | ||||
|         sub = MovieSubscription.objects.create( | ||||
|             user=request.user, | ||||
|             movie_id=movie_id, | ||||
|             title=request.POST.get('title', ''), | ||||
|             poster=request.POST.get('poster', ''), | ||||
|             overview=request.POST.get('overview', ''), | ||||
|             genres=request.POST.getlist('genres[]', []), | ||||
|             release_date=request.POST.get('release_date') | ||||
|         ) | ||||
|         return JsonResponse({'success': True}) | ||||
|  | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @require_POST | ||||
| @login_required | ||||
| def unsubscribe_movie(request, movie_id): | ||||
|     """Film deabonnieren""" | ||||
|     try: | ||||
|         MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete() | ||||
|         return JsonResponse({'success': True}) | ||||
|     except Exception as e: | ||||
|         return JsonResponse({'error': str(e)}, status=400) | ||||
|  | ||||
| @login_required | ||||
| def is_subscribed_movie(request, movie_id): | ||||
|     """Prüfe ob Film abonniert ist""" | ||||
|     is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists() | ||||
|     return JsonResponse({'subscribed': is_subbed}) | ||||
|  | ||||
| @login_required | ||||
| def get_subscriptions(request): | ||||
|     """Hole alle Abonnements des Users""" | ||||
|     series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True) | ||||
|     movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True) | ||||
|     return JsonResponse({ | ||||
|         'series': list(series), | ||||
|         'movies': list(movies) | ||||
|     }) | ||||
|  | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class SeriesSubscribeView(APIView): | ||||
|     def post(self, request, series_id): | ||||
|         from .services import sonarr_get_series | ||||
|         cfg = AppSettings.current() | ||||
|         details = None | ||||
|         try: | ||||
|             details = sonarr_get_series(series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key) | ||||
|         except Exception: | ||||
|             details = None | ||||
|         defaults = { | ||||
|             'series_title': request.data.get('title', '') if request.data else '', | ||||
|             'series_poster': request.data.get('poster', '') if request.data else '', | ||||
|             'series_overview': request.data.get('overview', '') if request.data else '', | ||||
|             'series_genres': request.data.get('genres', []) if request.data else [], | ||||
|         } | ||||
|         if details: | ||||
|             defaults.update({ | ||||
|                 'series_title': details.get('series_title') or defaults['series_title'], | ||||
|                 'series_poster': details.get('series_poster') or defaults['series_poster'], | ||||
|                 'series_overview': details.get('series_overview') or defaults['series_overview'], | ||||
|                 'series_genres': details.get('series_genres') or defaults['series_genres'], | ||||
|             }) | ||||
|         sub, created = SeriesSubscription.objects.get_or_create( | ||||
|             user=request.user, | ||||
|             series_id=series_id, | ||||
|             defaults=defaults | ||||
|         ) | ||||
|         return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class SeriesUnsubscribeView(APIView): | ||||
|     def post(self, request, series_id): | ||||
|         SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete() | ||||
|         return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class MovieSubscribeView(APIView): | ||||
|     def post(self, request, title): | ||||
|         from .services import radarr_lookup_movie_by_title | ||||
|         cfg = AppSettings.current() | ||||
|         details = None | ||||
|         try: | ||||
|             details = radarr_lookup_movie_by_title(title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key) | ||||
|         except Exception: | ||||
|             details = None | ||||
|         defaults = { | ||||
|             'movie_id': (request.data.get('movie_id', 0) if request.data else 0) or 0, | ||||
|             'poster': request.data.get('poster', '') if request.data else '', | ||||
|             'overview': request.data.get('overview', '') if request.data else '', | ||||
|             'genres': request.data.get('genres', []) if request.data else [], | ||||
|         } | ||||
|         if details: | ||||
|             defaults.update({ | ||||
|                 'movie_id': details.get('movie_id') or defaults['movie_id'], | ||||
|                 'poster': details.get('poster') or defaults['poster'], | ||||
|                 'overview': details.get('overview') or defaults['overview'], | ||||
|                 'genres': details.get('genres') or defaults['genres'], | ||||
|             }) | ||||
|         sub, created = MovieSubscription.objects.get_or_create( | ||||
|             user=request.user, | ||||
|             title=title, | ||||
|             defaults=defaults | ||||
|         ) | ||||
|         return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class MovieUnsubscribeView(APIView): | ||||
|     def post(self, request, title): | ||||
|         MovieSubscription.objects.filter(user=request.user, title=title).delete() | ||||
|         return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class ListSeriesSubscriptionsView(APIView): | ||||
|     def get(self, request): | ||||
|         subs = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True) | ||||
|         return Response(list(subs)) | ||||
|  | ||||
| @method_decorator(login_required, name='dispatch') | ||||
| class ListMovieSubscriptionsView(APIView): | ||||
|     def get(self, request): | ||||
|         subs = MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True) | ||||
|         return Response(list(subs)) | ||||
							
								
								
									
										39
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| version: '3.8' | ||||
| services: | ||||
|   subscribarr: | ||||
|     build: . | ||||
|     container_name: subscribarr | ||||
|     ports: | ||||
|       - "8000:8000" | ||||
|     environment: | ||||
|       # Django | ||||
|       - DJANGO_DEBUG=true | ||||
|       - DJANGO_ALLOWED_HOSTS=* | ||||
|       - DJANGO_SECRET_KEY=change-me | ||||
|       - DB_PATH=/app/data/db.sqlite3 | ||||
|       - NOTIFICATIONS_ALLOW_DUPLICATES=false | ||||
|       # App Settings (optional, otherwise use first-run setup) | ||||
|       - JELLYFIN_URL= | ||||
|       - JELLYFIN_API_KEY= | ||||
|       - SONARR_URL= | ||||
|       - SONARR_API_KEY= | ||||
|       - RADARR_URL= | ||||
|       - RADARR_API_KEY= | ||||
|       - MAIL_HOST= | ||||
|       - MAIL_PORT= | ||||
|       - MAIL_SECURE= | ||||
|       - MAIL_USER= | ||||
|       - MAIL_PASSWORD= | ||||
|       - MAIL_FROM= | ||||
|       # Admin bootstrap (optional) | ||||
|       - ADMIN_USERNAME= | ||||
|       - ADMIN_PASSWORD= | ||||
|       - ADMIN_EMAIL= | ||||
|       # Cron schedule (default every 30min) | ||||
|       - CRON_SCHEDULE=*/30 * * * * | ||||
|     volumes: | ||||
|       - subscribarr-data:/app/data | ||||
|     restart: unless-stopped | ||||
|  | ||||
| volumes: | ||||
|   subscribarr-data: | ||||
							
								
								
									
										88
									
								
								docker/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								docker/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| #!/usr/bin/env bash | ||||
| set -euo pipefail | ||||
|  | ||||
| # Wait for potential dependencies (none for sqlite) | ||||
|  | ||||
| # Apply migrations | ||||
| python manage.py migrate --noinput | ||||
|  | ||||
| # Create admin user if provided | ||||
| if [[ -n "${ADMIN_USERNAME:-}" && -n "${ADMIN_PASSWORD:-}" ]]; then | ||||
|   echo "Creating admin user ${ADMIN_USERNAME}" | ||||
|   python - <<'PY' | ||||
| import os | ||||
| from django.contrib.auth import get_user_model | ||||
| import django | ||||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings') | ||||
| django.setup() | ||||
| User = get_user_model() | ||||
| username = os.environ['ADMIN_USERNAME'] | ||||
| password = os.environ['ADMIN_PASSWORD'] | ||||
| email = os.environ.get('ADMIN_EMAIL') or f"{username}@local" | ||||
| user, created = User.objects.get_or_create(username=username, defaults={'email': email}) | ||||
| if created: | ||||
|     user.set_password(password) | ||||
|     user.is_superuser = True | ||||
|     user.is_staff = True | ||||
|     user.is_admin = True | ||||
|     user.save() | ||||
| else: | ||||
|     # update password if user exists | ||||
|     user.set_password(password) | ||||
|     user.is_superuser = True | ||||
|     user.is_staff = True | ||||
|     user.is_admin = True | ||||
|     user.save() | ||||
| print("Admin ready") | ||||
| PY | ||||
| fi | ||||
|  | ||||
| # Seed AppSettings from environment if provided | ||||
| python - <<'PY' | ||||
| import os, django | ||||
| os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings') | ||||
| django.setup() | ||||
| from settingspanel.models import AppSettings | ||||
| s = AppSettings.current() | ||||
| # Jellyfin | ||||
| jf_url = os.environ.get('JELLYFIN_URL') | ||||
| jf_key = os.environ.get('JELLYFIN_API_KEY') | ||||
| if jf_url: s.jellyfin_server_url = jf_url | ||||
| if jf_key: s.jellyfin_api_key = jf_key | ||||
| # Sonarr / Radarr | ||||
| sonarr_url = os.environ.get('SONARR_URL') | ||||
| sonarr_key = os.environ.get('SONARR_API_KEY') | ||||
| radarr_url = os.environ.get('RADARR_URL') | ||||
| radarr_key = os.environ.get('RADARR_API_KEY') | ||||
| if sonarr_url: s.sonarr_url = sonarr_url | ||||
| if sonarr_key: s.sonarr_api_key = sonarr_key | ||||
| if radarr_url: s.radarr_url = radarr_url | ||||
| if radarr_key: s.radarr_api_key = radarr_key | ||||
| # Mail | ||||
| mail_host = os.environ.get('MAIL_HOST') | ||||
| mail_port = os.environ.get('MAIL_PORT') | ||||
| mail_secure = os.environ.get('MAIL_SECURE') | ||||
| mail_user = os.environ.get('MAIL_USER') | ||||
| mail_password = os.environ.get('MAIL_PASSWORD') | ||||
| mail_from = os.environ.get('MAIL_FROM') | ||||
| if mail_host: s.mail_host = mail_host | ||||
| if mail_port: s.mail_port = int(mail_port) | ||||
| if mail_secure: s.mail_secure = mail_secure | ||||
| if mail_user: s.mail_user = mail_user | ||||
| if mail_password: s.mail_password = mail_password | ||||
| if mail_from: s.mail_from = mail_from | ||||
| s.save() | ||||
| print("AppSettings seeded from environment (if provided)") | ||||
| PY | ||||
|  | ||||
| # Start cron for periodic job if schedule is set | ||||
| if [[ -n "${CRON_SCHEDULE:-}" ]]; then | ||||
|   echo "Setting cron schedule: ${CRON_SCHEDULE}" | ||||
|   # write cronjob to user crontab | ||||
|   CRONLINE="${CRON_SCHEDULE} cd /app && /usr/local/bin/python manage.py check_new_media >> /app/cron.log 2>&1" | ||||
|   (crontab -l 2>/dev/null; echo "$CRONLINE") | crontab - | ||||
|   crond | ||||
| fi | ||||
|  | ||||
| # Start server | ||||
| exec python manage.py runserver 0.0.0.0:8000 | ||||
| @@ -2,6 +2,58 @@ from django import forms | ||||
|  | ||||
| WIDE = {"class": "input-wide"} | ||||
|  | ||||
| class FirstRunSetupForm(forms.Form): | ||||
|     # Jellyfin (Required) | ||||
|     jellyfin_server_url = forms.URLField( | ||||
|         label="Jellyfin Server URL", | ||||
|         required=True, | ||||
|         help_text="Die URL deines Jellyfin-Servers" | ||||
|     ) | ||||
|     jellyfin_api_key = forms.CharField( | ||||
|         label="Jellyfin API Key", | ||||
|         required=True, | ||||
|         widget=forms.PasswordInput(render_value=True), | ||||
|         help_text="Der API-Key aus den Jellyfin-Einstellungen" | ||||
|     ) | ||||
|      | ||||
|     # Sonarr (Optional) | ||||
|     sonarr_url = forms.URLField( | ||||
|         label="Sonarr URL", | ||||
|         required=False, | ||||
|         help_text="Die URL deines Sonarr-Servers" | ||||
|     ) | ||||
|     sonarr_api_key = forms.CharField( | ||||
|         label="Sonarr API Key", | ||||
|         required=False, | ||||
|         widget=forms.PasswordInput(render_value=True) | ||||
|     ) | ||||
|      | ||||
|     # Radarr (Optional) | ||||
|     radarr_url = forms.URLField( | ||||
|         label="Radarr URL", | ||||
|         required=False, | ||||
|         help_text="Die URL deines Radarr-Servers" | ||||
|     ) | ||||
|     radarr_api_key = forms.CharField( | ||||
|         label="Radarr API Key", | ||||
|         required=False, | ||||
|         widget=forms.PasswordInput(render_value=True) | ||||
|     ) | ||||
|  | ||||
| class JellyfinSettingsForm(forms.Form): | ||||
|     jellyfin_server_url = forms.URLField( | ||||
|         label="Jellyfin Server URL", | ||||
|         required=False, | ||||
|         widget=forms.URLInput(attrs=WIDE), | ||||
|         help_text="z.B. http://localhost:8096" | ||||
|     ) | ||||
|     jellyfin_api_key = forms.CharField( | ||||
|         label="Jellyfin API Key", | ||||
|         required=False, | ||||
|         widget=forms.PasswordInput(render_value=True, attrs=WIDE), | ||||
|         help_text="Admin API Key aus den Jellyfin Einstellungen" | ||||
|     ) | ||||
|  | ||||
| class ArrSettingsForm(forms.Form): | ||||
|     sonarr_url     = forms.URLField(label="Sonarr URL", required=False, | ||||
|                                     widget=forms.URLInput(attrs=WIDE)) | ||||
|   | ||||
							
								
								
									
										23
									
								
								settingspanel/middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								settingspanel/middleware.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| from django.shortcuts import redirect | ||||
| from django.urls import reverse | ||||
| from django.conf import settings | ||||
| from .views import needs_setup | ||||
|  | ||||
| class SetupMiddleware: | ||||
|     def __init__(self, get_response): | ||||
|         self.get_response = get_response | ||||
|  | ||||
|     def __call__(self, request): | ||||
|         if needs_setup(): | ||||
|             # URLs, die auch ohne Setup erlaubt sind | ||||
|             allowed_urls = [ | ||||
|                 reverse('settingspanel:setup'), | ||||
|                 '/static/',  # Für CSS/JS | ||||
|             ] | ||||
|              | ||||
|             # Prüfe, ob die aktuelle URL erlaubt ist | ||||
|             if not any(request.path.startswith(url) for url in allowed_urls): | ||||
|                 return redirect('settingspanel:setup') | ||||
|          | ||||
|         response = self.get_response(request) | ||||
|         return response | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 5.2.5 on 2025-08-10 13:15 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('settingspanel', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='appsettings', | ||||
|             name='jellyfin_api_key', | ||||
|             field=models.CharField(blank=True, max_length=255, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='appsettings', | ||||
|             name='jellyfin_server_url', | ||||
|             field=models.URLField(blank=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -4,6 +4,10 @@ class AppSettings(models.Model): | ||||
|     # Singleton-Pattern über feste ID | ||||
|     singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) | ||||
|  | ||||
|     # Jellyfin | ||||
|     jellyfin_server_url = models.URLField(blank=True, null=True) | ||||
|     jellyfin_api_key = models.CharField(max_length=255, blank=True, null=True) | ||||
|  | ||||
|     # Arr | ||||
|     sonarr_url = models.URLField(blank=True, null=True) | ||||
|     sonarr_api_key = models.CharField(max_length=255, blank=True, null=True) | ||||
| @@ -15,7 +19,12 @@ class AppSettings(models.Model): | ||||
|     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")) | ||||
|         choices=( | ||||
|             ("", "Kein TLS/SSL"), | ||||
|             ("starttls", "STARTTLS (Port 587)"), | ||||
|             ("ssl", "SSL/TLS (Port 465)"), | ||||
|             ("tls", "TLS (alias STARTTLS)"), | ||||
|         ) | ||||
|     ) | ||||
|     mail_user = models.CharField(max_length=255, blank=True, null=True) | ||||
|     mail_password = models.CharField(max_length=255, blank=True, null=True) | ||||
| @@ -32,5 +41,15 @@ class AppSettings(models.Model): | ||||
|  | ||||
|     @classmethod | ||||
|     def current(cls): | ||||
|         """Get the current settings instance or create a new one""" | ||||
|         obj, _ = cls.objects.get_or_create(singleton_id=1) | ||||
|         return obj | ||||
|          | ||||
|     def get_jellyfin_url(self): | ||||
|         """Get the Jellyfin server URL with proper formatting""" | ||||
|         if not self.jellyfin_server_url: | ||||
|             return None | ||||
|         url = self.jellyfin_server_url | ||||
|         if not url.startswith(('http://', 'https://')): | ||||
|             url = f'http://{url}' | ||||
|         return url.rstrip('/') | ||||
|   | ||||
							
								
								
									
										57
									
								
								settingspanel/templates/settingspanel/first_run.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								settingspanel/templates/settingspanel/first_run.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| {% extends "base.html" %} | ||||
| {% load static %} | ||||
|  | ||||
| {% block extra_style %} | ||||
| <link rel="stylesheet" href="{% static 'css/setup.css' %}"> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="setup-container"> | ||||
|     <h1>Willkommen bei Subscribarr</h1> | ||||
|     <p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p> | ||||
|  | ||||
|     <form method="post" class="setup-form"> | ||||
|         {% csrf_token %} | ||||
|  | ||||
|         <div class="setup-section"> | ||||
|             <h2>Jellyfin Server (Erforderlich)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ form.jellyfin_server_url }} | ||||
|                 <div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div> | ||||
|             </div> | ||||
|             <div class="form-group"> | ||||
|                 <label>API Key</label> | ||||
|                 {{ form.jellyfin_api_key }} | ||||
|                 <div class="help">Admin API Key aus den Jellyfin-Einstellungen</div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="setup-section"> | ||||
|             <h2>Sonarr (Optional)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ form.sonarr_url }} | ||||
|             </div> | ||||
|             <div class="form-group"> | ||||
|                 <label>API Key</label> | ||||
|                 {{ form.sonarr_api_key }} | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="setup-section"> | ||||
|             <h2>Radarr (Optional)</h2> | ||||
|             <div class="form-group"> | ||||
|                 <label>Server URL</label> | ||||
|                 {{ form.radarr_url }} | ||||
|             </div> | ||||
|             <div class="form-group"> | ||||
|                 <label>API Key</label> | ||||
|                 {{ form.radarr_api_key }} | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <button type="submit" class="setup-submit">Installation abschließen</button> | ||||
|     </form> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -6,201 +6,7 @@ | ||||
|     <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> | ||||
|     <link rel="stylesheet" href="{% static 'css/settings.css' %}"> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
| @@ -218,6 +24,20 @@ | ||||
|         <form method="post"> | ||||
|             {% csrf_token %} | ||||
|             <div class="grid"> | ||||
|                 <div class="card"> | ||||
|                     <h2>Jellyfin</h2> | ||||
|                     <div class="row"> | ||||
|                         <label>Jellyfin Server URL</label> | ||||
|                         {{ jellyfin_form.jellyfin_server_url }} | ||||
|                         <div class="help">z.B. http://localhost:8096</div> | ||||
|                     </div> | ||||
|                     <div class="row"> | ||||
|                         <label>Jellyfin API Key</label> | ||||
|                         {{ jellyfin_form.jellyfin_api_key }} | ||||
|                         <div class="help">Admin API Key aus den Jellyfin Einstellungen</div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="card"> | ||||
|                     <h2>Sonarr & Radarr</h2> | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| from django.urls import path | ||||
| from .views import SettingsView, test_connection | ||||
| from .views import SettingsView, test_connection, first_run | ||||
|  | ||||
| app_name = "settingspanel" | ||||
| urlpatterns = [ | ||||
|     path("", SettingsView.as_view(), name="index"), | ||||
|     path("test-connection/", test_connection, name="test_connection"), | ||||
|     path("setup/", first_run, name="setup"), | ||||
| ] | ||||
|   | ||||
| @@ -1,11 +1,53 @@ | ||||
| from django.views import View | ||||
| from django.shortcuts import render, redirect | ||||
| from django.views import View | ||||
| from django.contrib import messages | ||||
| from .forms import ArrSettingsForm, MailSettingsForm, AccountForm | ||||
| from django.utils.decorators import method_decorator | ||||
| from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, FirstRunSetupForm, JellyfinSettingsForm | ||||
| from .models import AppSettings | ||||
| from django.http import JsonResponse | ||||
| from accounts.utils import jellyfin_admin_required | ||||
| from django.contrib.auth import get_user_model | ||||
| import requests | ||||
|  | ||||
| def needs_setup(): | ||||
|     """Check if the app needs first-run setup""" | ||||
|     settings = AppSettings.current() | ||||
|     return not bool(settings.jellyfin_server_url) | ||||
|  | ||||
| def first_run(request): | ||||
|     """Handle first-run setup""" | ||||
|     if not needs_setup(): | ||||
|         return redirect('arr_api:index') | ||||
|          | ||||
|     if request.method == 'POST': | ||||
|         form = FirstRunSetupForm(request.POST) | ||||
|         if form.is_valid(): | ||||
|             # Save settings | ||||
|             settings = AppSettings.current() | ||||
|             settings.jellyfin_server_url = form.cleaned_data['jellyfin_server_url'] | ||||
|             settings.jellyfin_api_key = form.cleaned_data['jellyfin_api_key'] | ||||
|             settings.sonarr_url = form.cleaned_data['sonarr_url'] | ||||
|             settings.sonarr_api_key = form.cleaned_data['sonarr_api_key'] | ||||
|             settings.radarr_url = form.cleaned_data['radarr_url'] | ||||
|             settings.radarr_api_key = form.cleaned_data['radarr_api_key'] | ||||
|             settings.save() | ||||
|              | ||||
|             messages.success(request, 'Setup erfolgreich abgeschlossen!') | ||||
|             return redirect('accounts:login') | ||||
|     else: | ||||
|         form = FirstRunSetupForm() | ||||
|      | ||||
|     return render(request, 'settingspanel/first_run.html', {'form': form}) | ||||
| from django.shortcuts import render, redirect | ||||
| from django.contrib import messages | ||||
| from django.utils.decorators import method_decorator | ||||
| from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, JellyfinSettingsForm | ||||
| from .models import AppSettings | ||||
| from django.http import JsonResponse | ||||
| from accounts.utils import jellyfin_admin_required | ||||
| import requests | ||||
|  | ||||
| @jellyfin_admin_required | ||||
| def test_connection(request): | ||||
|     kind = request.GET.get("kind")  # "sonarr" | "radarr" | ||||
|     url = (request.GET.get("url") or "").strip() | ||||
| @@ -27,12 +69,17 @@ def test_connection(request): | ||||
|     except requests.RequestException as e: | ||||
|         return JsonResponse({"ok": False, "error": str(e)}) | ||||
|  | ||||
| @method_decorator(jellyfin_admin_required, name='dispatch') | ||||
| class SettingsView(View): | ||||
|     template_name = "settingspanel/settings.html" | ||||
|  | ||||
|     def get(self, request): | ||||
|         cfg = AppSettings.current() | ||||
|         return render(request, self.template_name, { | ||||
|             "jellyfin_form": JellyfinSettingsForm(initial={ | ||||
|                 "jellyfin_server_url": cfg.jellyfin_server_url or "", | ||||
|                 "jellyfin_api_key": cfg.jellyfin_api_key or "", | ||||
|             }), | ||||
|             "arr_form": ArrSettingsForm(initial={ | ||||
|                 "sonarr_url": cfg.sonarr_url or "", | ||||
|                 "sonarr_api_key": cfg.sonarr_api_key or "", | ||||
| @@ -54,15 +101,24 @@ class SettingsView(View): | ||||
|         }) | ||||
|  | ||||
|     def post(self, request): | ||||
|         jellyfin_form = JellyfinSettingsForm(request.POST) | ||||
|         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()): | ||||
|          | ||||
|         if not (jellyfin_form.is_valid() and 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 | ||||
|                 "jellyfin_form": jellyfin_form, | ||||
|                 "arr_form": arr_form, | ||||
|                 "mail_form": mail_form, | ||||
|                 "account_form": acc_form | ||||
|             }) | ||||
|  | ||||
|         cfg = AppSettings.current() | ||||
|          | ||||
|         # Update Jellyfin settings | ||||
|         cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None | ||||
|         cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None | ||||
|         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 | ||||
|   | ||||
							
								
								
									
										160
									
								
								static/css/base.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								static/css/base.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| :root { | ||||
|     --bg: #0b0b10; | ||||
|     --panel: #12121a; | ||||
|     --panel-b: #1f2030; | ||||
|     --accent: #3b82f6; | ||||
|     --muted: #9aa0b4; | ||||
|     --text: #e6e6e6; | ||||
| } | ||||
|  | ||||
| body { | ||||
|     font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif; | ||||
|     background: var(--bg); | ||||
|     color: var(--text); | ||||
|     margin: 0; | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| .auth-container { | ||||
|     max-width: 400px; | ||||
|     margin: 40px auto; | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .auth-form { | ||||
|     background: var(--panel); | ||||
|     border: 1px solid var(--panel-b); | ||||
|     border-radius: 12px; | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .auth-form label { | ||||
|     display: block; | ||||
|     margin-bottom: 4px; | ||||
|     color: var(--muted); | ||||
| } | ||||
|  | ||||
| .auth-form input:not([type="checkbox"]) { | ||||
|     width: 100%; | ||||
|     padding: 8px 12px; | ||||
|     border-radius: 8px; | ||||
|     border: 1px solid #2a2a34; | ||||
|     background: #111119; | ||||
|     color: var(--text); | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .auth-form input:focus { | ||||
|     border-color: var(--accent); | ||||
|     outline: none; | ||||
| } | ||||
|  | ||||
| .btn-primary { | ||||
|     display: inline-block; | ||||
|     padding: 10px 20px; | ||||
|     background: var(--accent); | ||||
|     color: #fff; | ||||
|     border: none; | ||||
|     border-radius: 8px; | ||||
|     cursor: pointer; | ||||
|     font-size: 1rem; | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| .btn-primary:hover { | ||||
|     filter: brightness(1.1); | ||||
| } | ||||
|  | ||||
| .auth-links { | ||||
|     margin-top: 20px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .auth-links a { | ||||
|     color: var(--accent); | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| .auth-links a:hover { | ||||
|     text-decoration: underline; | ||||
| } | ||||
|  | ||||
| .errorlist { | ||||
|     color: #ef4444; | ||||
|     margin: 0 0 16px; | ||||
|     padding: 0; | ||||
|     list-style: none; | ||||
| } | ||||
|  | ||||
| .helptext { | ||||
|     display: block; | ||||
|     font-size: 0.9rem; | ||||
|     color: var(--muted); | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .main-nav { | ||||
|     background: var(--panel); | ||||
|     border-bottom: 1px solid var(--panel-b); | ||||
|     padding: 12px 16px; | ||||
| } | ||||
|  | ||||
| .nav-content { | ||||
|     max-width: 1200px; | ||||
|     margin: 0 auto; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .nav-brand { | ||||
|     color: var(--text); | ||||
|     text-decoration: none; | ||||
|     font-weight: 600; | ||||
|     font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .nav-links { | ||||
|     display: flex; | ||||
|     gap: 16px; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .nav-links a, | ||||
| .nav-links button { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
|     color: var(--text); | ||||
|     text-decoration: none; | ||||
|     padding: 6px 12px; | ||||
|     border-radius: 6px; | ||||
|     transition: background-color 0.2s; | ||||
| } | ||||
|  | ||||
| .nav-links a:hover, | ||||
| .nav-links button:hover { | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
| } | ||||
|  | ||||
| .user-info { | ||||
|     color: var(--muted); | ||||
|     margin-right: 8px; | ||||
| } | ||||
|  | ||||
| .nav-register { | ||||
|     background: var(--accent) !important; | ||||
| } | ||||
|  | ||||
| .nav-register:hover { | ||||
|     filter: brightness(1.1); | ||||
|     background: var(--accent); | ||||
| } | ||||
|  | ||||
| .nav-logout { | ||||
|     color: #ef4444 !important; | ||||
| } | ||||
|  | ||||
| .nav-admin { | ||||
|     color: var(--accent) !important; | ||||
| } | ||||
							
								
								
									
										477
									
								
								static/css/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										477
									
								
								static/css/index.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,477 @@ | ||||
| /* Variables and base resets for the index page */ | ||||
| :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: .92rem; | ||||
|     padding: 6px 0; | ||||
|     border-bottom: 1px dashed #25263a | ||||
| } | ||||
|  | ||||
| .muted { | ||||
|     color: var(--muted); | ||||
|     font-size: .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 | ||||
| } | ||||
|  | ||||
| /* Subscription buttons */ | ||||
| .subscribe-btn { | ||||
|     display: inline-block; | ||||
|     padding: 6px 12px; | ||||
|     border-radius: 6px; | ||||
|     border: 1px solid var(--accent); | ||||
|     background: transparent; | ||||
|     color: var(--accent); | ||||
|     text-decoration: none; | ||||
|     font-size: .9rem; | ||||
|     cursor: pointer; | ||||
|     transition: all .2s ease | ||||
| } | ||||
|  | ||||
| .subscribe-btn:hover { | ||||
|     background: var(--accent); | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| .subscribe-btn.subscribed { | ||||
|     background: var(--accent); | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| .subscribe-btn.unsubscribe { | ||||
|     border-color: #ef4444; | ||||
|     color: #ef4444 | ||||
| } | ||||
|  | ||||
| .subscribe-btn.unsubscribe:hover { | ||||
|     background: #ef4444; | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| /* 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; | ||||
|     appearance: textfield; | ||||
|     -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; | ||||
|     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; | ||||
|     transition: transform .08s ease, border-color .08s | ||||
| } | ||||
|  | ||||
| .movie-card:hover, | ||||
| .movie-card:focus-within { | ||||
|     transform: translateY(-2px); | ||||
|     border-color: #2a2b44 | ||||
| } | ||||
|  | ||||
| .movie-card:active { | ||||
|     transform: translateY(0) | ||||
| } | ||||
|  | ||||
| /* Error toast */ | ||||
| .error-message { | ||||
|     position: fixed; | ||||
|     bottom: 20px; | ||||
|     left: 50%; | ||||
|     transform: translateX(-50%); | ||||
|     background: #ef4444; | ||||
|     color: #fff; | ||||
|     padding: 12px 24px; | ||||
|     border-radius: 8px; | ||||
|     box-shadow: 0 4px 6px rgba(0, 0, 0, .1); | ||||
|     z-index: 1000; | ||||
|     animation: slideUp .3s ease | ||||
| } | ||||
|  | ||||
| @keyframes slideUp { | ||||
|     from { | ||||
|         transform: translate(-50%, 100%); | ||||
|         opacity: 0 | ||||
|     } | ||||
|  | ||||
|     to { | ||||
|         transform: translate(-50%, 0); | ||||
|         opacity: 1 | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Debug info */ | ||||
| .debug-info { | ||||
|     text-align: right; | ||||
|     margin: 0 16px 12px; | ||||
|     color: var(--muted); | ||||
|     font-size: .9rem | ||||
| } | ||||
							
								
								
									
										155
									
								
								static/css/profile.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								static/css/profile.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| .profile-container { | ||||
|     max-width: 800px; | ||||
|     margin: 0 auto; | ||||
|     padding: 20px | ||||
| } | ||||
|  | ||||
| .profile-section { | ||||
|     background: var(--panel); | ||||
|     border: 1px solid var(--panel-b); | ||||
|     border-radius: 12px; | ||||
|     padding: 20px; | ||||
|     margin: 20px 0 | ||||
| } | ||||
|  | ||||
| .subscription-list { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|     gap: 12px; | ||||
|     margin-top: 12px | ||||
| } | ||||
|  | ||||
| .subscription-item { | ||||
|     display: flex; | ||||
|     gap: 12px; | ||||
|     background: rgba(0, 0, 0, .2); | ||||
|     padding: 12px; | ||||
|     border-radius: 8px; | ||||
|     transition: transform .2s ease; | ||||
|     cursor: pointer | ||||
| } | ||||
|  | ||||
| .subscription-item:hover { | ||||
|     transform: translateY(-2px); | ||||
|     background: rgba(0, 0, 0, .3) | ||||
| } | ||||
|  | ||||
| .subscription-poster { | ||||
|     width: 80px; | ||||
|     height: 120px; | ||||
|     object-fit: cover; | ||||
|     border-radius: 6px; | ||||
|     box-shadow: 0 2px 4px rgba(0, 0, 0, .2) | ||||
| } | ||||
|  | ||||
| .subscription-info { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center | ||||
| } | ||||
|  | ||||
| .subscription-title { | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 4px | ||||
| } | ||||
|  | ||||
| .subscription-date { | ||||
|     font-size: .9rem; | ||||
|     color: var(--muted); | ||||
|     margin-bottom: 4px | ||||
| } | ||||
|  | ||||
| .subscription-overview { | ||||
|     font-size: .9rem; | ||||
|     color: var(--muted); | ||||
|     line-height: 1.4; | ||||
|     opacity: .9 | ||||
| } | ||||
|  | ||||
| .messages { | ||||
|     margin-bottom: 20px | ||||
| } | ||||
|  | ||||
| .message { | ||||
|     padding: 12px; | ||||
|     border-radius: 8px; | ||||
|     margin-bottom: 8px | ||||
| } | ||||
|  | ||||
| .message.success { | ||||
|     background: #1f6f3a; | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| .message.error { | ||||
|     background: #ef4444; | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| .btn-secondary { | ||||
|     display: inline-block; | ||||
|     padding: 8px 16px; | ||||
|     background: #1f2030; | ||||
|     color: var(--text); | ||||
|     text-decoration: none; | ||||
|     border-radius: 8px; | ||||
|     border: 1px solid #2a2b44 | ||||
| } | ||||
|  | ||||
| .btn-secondary:hover { | ||||
|     background: #2a2b44 | ||||
| } | ||||
|  | ||||
| .mt-4 { | ||||
|     margin-top: 16px | ||||
| } | ||||
|  | ||||
| .badge { | ||||
|     display: inline-block; | ||||
|     padding: 4px 8px; | ||||
|     border-radius: 999px; | ||||
|     font-size: .875rem; | ||||
|     font-weight: 500 | ||||
| } | ||||
|  | ||||
| .badge-admin { | ||||
|     background-color: #1f6f3a; | ||||
|     color: #fff | ||||
| } | ||||
|  | ||||
| .jellyfin-info { | ||||
|     margin-top: 24px; | ||||
|     padding: 16px; | ||||
|     background: var(--panel); | ||||
|     border: 1px solid var(--panel-b); | ||||
|     border-radius: 8px | ||||
| } | ||||
|  | ||||
| .jellyfin-info h4 { | ||||
|     margin: 0 0 12px 0; | ||||
|     color: var(--accent) | ||||
| } | ||||
|  | ||||
| .compact-form .form-row { | ||||
|     display: grid; | ||||
|     grid-template-columns: 140px 1fr; | ||||
|     gap: 10px; | ||||
|     align-items: center; | ||||
|     margin-bottom: 10px | ||||
| } | ||||
|  | ||||
| .text-input { | ||||
|     width: 100%; | ||||
|     max-width: 100%; | ||||
|     min-width: 0; | ||||
|     padding: 10px 12px; | ||||
|     border-radius: 10px; | ||||
|     border: 1px solid #2a2a34; | ||||
|     background: #111119; | ||||
|     color: var(--text) | ||||
| } | ||||
|  | ||||
| .text-input:focus { | ||||
|     border-color: var(--accent); | ||||
|     outline: none | ||||
| } | ||||
							
								
								
									
										178
									
								
								static/css/settings.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								static/css/settings.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| :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); | ||||
|     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 | ||||
| } | ||||
|  | ||||
| .inline>input, | ||||
| .inline>.django-url, | ||||
| .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: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; | ||||
|     max-width: 140px; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis | ||||
| } | ||||
|  | ||||
| .badge.ok { | ||||
|     border-color: #1f6f3a; | ||||
|     background: #10331f; | ||||
|     color: #a7e3bd | ||||
| } | ||||
|  | ||||
| .badge.err { | ||||
|     border-color: #6f1f2a; | ||||
|     background: #341016; | ||||
|     color: #f1a3b0 | ||||
| } | ||||
|  | ||||
| .badge.muted { | ||||
|     opacity: .8 | ||||
| } | ||||
							
								
								
									
										71
									
								
								static/css/setup.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								static/css/setup.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| .setup-container { | ||||
|     max-width: 800px; | ||||
|     margin: 40px auto; | ||||
|     padding: 20px | ||||
| } | ||||
|  | ||||
| .setup-intro { | ||||
|     color: var(--muted); | ||||
|     font-size: 1.1em; | ||||
|     margin-bottom: 30px | ||||
| } | ||||
|  | ||||
| .setup-section { | ||||
|     background: var(--panel); | ||||
|     border: 1px solid var(--panel-b); | ||||
|     border-radius: 12px; | ||||
|     padding: 20px; | ||||
|     margin-bottom: 20px | ||||
| } | ||||
|  | ||||
| .setup-section h2 { | ||||
|     margin: 0 0 20px; | ||||
|     font-size: 1.2em | ||||
| } | ||||
|  | ||||
| .form-group { | ||||
|     margin-bottom: 16px | ||||
| } | ||||
|  | ||||
| .form-group label { | ||||
|     display: block; | ||||
|     margin-bottom: 8px; | ||||
|     color: var(--muted) | ||||
| } | ||||
|  | ||||
| .form-group input { | ||||
|     width: 100%; | ||||
|     padding: 10px; | ||||
|     border-radius: 8px; | ||||
|     border: 1px solid var(--panel-b); | ||||
|     background: rgba(0, 0, 0, .2); | ||||
|     color: var(--text) | ||||
| } | ||||
|  | ||||
| .form-group input:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--accent) | ||||
| } | ||||
|  | ||||
| .help { | ||||
|     margin-top: 4px; | ||||
|     font-size: .9em; | ||||
|     color: var(--muted) | ||||
| } | ||||
|  | ||||
| .setup-submit { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 12px; | ||||
|     background: var(--accent); | ||||
|     color: #fff; | ||||
|     border: none; | ||||
|     border-radius: 8px; | ||||
|     font-size: 1.1em; | ||||
|     cursor: pointer; | ||||
|     transition: filter .2s | ||||
| } | ||||
|  | ||||
| .setup-submit:hover { | ||||
|     filter: brightness(1.1) | ||||
| } | ||||
| @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ | ||||
| """ | ||||
|  | ||||
| from pathlib import Path | ||||
| import os | ||||
|  | ||||
| # Build paths inside the project like this: BASE_DIR / 'subdir'. | ||||
| BASE_DIR = Path(__file__).resolve().parent.parent | ||||
| @@ -20,18 +21,17 @@ BASE_DIR = Path(__file__).resolve().parent.parent | ||||
| # 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' | ||||
| SECRET_KEY = os.getenv('DJANGO_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 | ||||
| DEBUG = os.getenv('DJANGO_DEBUG', 'True').lower() == 'true' | ||||
|  | ||||
| ALLOWED_HOSTS = [] | ||||
| ALLOWED_HOSTS = [h for h in os.getenv('DJANGO_ALLOWED_HOSTS', '').split(',') if h] or [] | ||||
|  | ||||
|  | ||||
| # Application definition | ||||
|  | ||||
| INSTALLED_APPS = [ | ||||
|     'django.contrib.admin', | ||||
|     'django.contrib.auth', | ||||
|     'django.contrib.contenttypes', | ||||
|     'django.contrib.sessions', | ||||
| @@ -40,6 +40,7 @@ INSTALLED_APPS = [ | ||||
|     'rest_framework', | ||||
|     'arr_api', | ||||
|     'settingspanel', | ||||
|     'accounts', | ||||
| ] | ||||
|  | ||||
| MIDDLEWARE = [ | ||||
| @@ -50,6 +51,7 @@ MIDDLEWARE = [ | ||||
|     'django.contrib.auth.middleware.AuthenticationMiddleware', | ||||
|     'django.contrib.messages.middleware.MessageMiddleware', | ||||
|     'django.middleware.clickjacking.XFrameOptionsMiddleware', | ||||
|     'settingspanel.middleware.SetupMiddleware', | ||||
| ] | ||||
|  | ||||
| ROOT_URLCONF = 'subscribarr.urls' | ||||
| @@ -57,7 +59,7 @@ ROOT_URLCONF = 'subscribarr.urls' | ||||
| TEMPLATES = [ | ||||
|     { | ||||
|         'BACKEND': 'django.template.backends.django.DjangoTemplates', | ||||
|         'DIRS': [], | ||||
|         'DIRS': [BASE_DIR / 'templates'], | ||||
|         'APP_DIRS': True, | ||||
|         'OPTIONS': { | ||||
|             'context_processors': [ | ||||
| @@ -75,10 +77,11 @@ WSGI_APPLICATION = 'subscribarr.wsgi.application' | ||||
| # Database | ||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#databases | ||||
|  | ||||
| _db_path = os.getenv('DB_PATH') | ||||
| DATABASES = { | ||||
|     'default': { | ||||
|         'ENGINE': 'django.db.backends.sqlite3', | ||||
|         'NAME': BASE_DIR / 'db.sqlite3', | ||||
|         'NAME': _db_path if _db_path else BASE_DIR / 'db.sqlite3', | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -105,9 +108,9 @@ AUTH_PASSWORD_VALIDATORS = [ | ||||
| # Internationalization | ||||
| # https://docs.djangoproject.com/en/5.2/topics/i18n/ | ||||
|  | ||||
| LANGUAGE_CODE = 'en-us' | ||||
| LANGUAGE_CODE = 'de-de' | ||||
|  | ||||
| TIME_ZONE = 'UTC' | ||||
| TIME_ZONE = 'Europe/Berlin' | ||||
|  | ||||
| USE_I18N = True | ||||
|  | ||||
| @@ -118,8 +121,39 @@ USE_TZ = True | ||||
| # https://docs.djangoproject.com/en/5.2/howto/static-files/ | ||||
|  | ||||
| STATIC_URL = 'static/' | ||||
| STATICFILES_DIRS = [BASE_DIR / 'static'] | ||||
| # STATIC_ROOT could be set for collectstatic in production | ||||
| # STATIC_ROOT = BASE_DIR / 'staticfiles' | ||||
|  | ||||
| # Default primary key field type | ||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field | ||||
|  | ||||
| DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | ||||
|  | ||||
| # Custom User Model | ||||
| AUTH_USER_MODEL = 'accounts.User' | ||||
|  | ||||
| # Login-URLs | ||||
| LOGIN_URL = '/accounts/login/' | ||||
| LOGIN_REDIRECT_URL = '/' | ||||
| LOGOUT_REDIRECT_URL = '/' | ||||
|  | ||||
| # Default Jellyfin Settings - will be overridden by database settings | ||||
| JELLYFIN_CLIENT = 'Subscribarr' | ||||
| JELLYFIN_VERSION = '10.10.7' | ||||
| JELLYFIN_DEVICE = 'Subscribarr' | ||||
| JELLYFIN_DEVICE_ID = 'subscribarr-instance' | ||||
|  | ||||
| # Email Settings - override these with settings from the database | ||||
| EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' | ||||
| EMAIL_HOST = None  # Will be set from AppSettings | ||||
| EMAIL_PORT = None  # Will be set from AppSettings | ||||
| EMAIL_USE_TLS = False | ||||
| EMAIL_USE_SSL = False | ||||
| EMAIL_HOST_USER = None  # Will be set from AppSettings | ||||
| EMAIL_HOST_PASSWORD = None  # Will be set from AppSettings | ||||
| DEFAULT_FROM_EMAIL = None  # Will be set from AppSettings | ||||
|  | ||||
| # Notifications / Debug | ||||
| # If True, duplicate suppression is disabled and emails can be resent on every run. | ||||
| NOTIFICATIONS_ALLOW_DUPLICATES = os.getenv('NOTIFICATIONS_ALLOW_DUPLICATES', 'True').lower() == 'true' | ||||
|   | ||||
| @@ -14,13 +14,10 @@ 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")), | ||||
|     path('', include('arr_api.urls')), | ||||
|     path('settings/', include('settingspanel.urls')), | ||||
|     path('accounts/', include('accounts.urls', namespace='accounts')), | ||||
| ] | ||||
|   | ||||
							
								
								
									
										73
									
								
								templates/base.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								templates/base.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| {% load static %} | ||||
| <!DOCTYPE html> | ||||
| <html lang="de"> | ||||
|  | ||||
| <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>{% block title %}Subscribarr{% endblock %}</title> | ||||
|     <link rel="stylesheet" href="{% static 'css/base.css' %}"> | ||||
|     {% block extra_style %}{% endblock %} | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <nav class="main-nav"> | ||||
|         <div class="nav-content"> | ||||
|             <a href="/" class="nav-brand">Subscribarr</a> | ||||
|             <div class="nav-links"> | ||||
|                 {% if user.is_authenticated %} | ||||
|                 <span class="user-info">Angemeldet als <strong>{{ user.username }}</strong></span> | ||||
|                 <a href="{% url 'accounts:profile' %}" class="nav-profile"> | ||||
|                     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||||
|                         <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> | ||||
|                         <circle cx="12" cy="7" r="4"></circle> | ||||
|                     </svg> | ||||
|                     Profil | ||||
|                 </a> | ||||
|                 {% if user.is_jellyfin_admin %} | ||||
|                 <a href="{% url 'settingspanel:index' %}" class="nav-admin"> | ||||
|                     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||||
|                         <path d="M12 20V10M12 10l-4-4m4 4l4-4M4 16.667V20h16v-3.333M4 7.333V4h16v3.333"></path> | ||||
|                     </svg> | ||||
|                     Administration | ||||
|                 </a> | ||||
|                 {% endif %} | ||||
|                 <form method="post" action="{% url 'accounts:logout' %}" class="inline-form"> | ||||
|                     {% csrf_token %} | ||||
|                     <button type="submit" class="nav-logout"> | ||||
|                         <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" | ||||
|                             stroke-width="2"> | ||||
|                             <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> | ||||
|                             <polyline points="16 17 21 12 16 7"></polyline> | ||||
|                             <line x1="21" y1="12" x2="9" y2="12"></line> | ||||
|                         </svg> | ||||
|                         Abmelden | ||||
|                     </button> | ||||
|                 </form> | ||||
|                 {% else %} | ||||
|                 <a href="{% url 'accounts:login' %}" class="nav-login"> | ||||
|                     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||||
|                         <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path> | ||||
|                         <polyline points="10 17 15 12 10 7"></polyline> | ||||
|                         <line x1="15" y1="12" x2="3" y2="12"></line> | ||||
|                     </svg> | ||||
|                     Anmelden | ||||
|                 </a> | ||||
|                 <a href="{% url 'accounts:register' %}" class="nav-register"> | ||||
|                     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||||
|                         <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path> | ||||
|                         <circle cx="8.5" cy="7" r="4"></circle> | ||||
|                         <line x1="20" y1="8" x2="20" y2="14"></line> | ||||
|                         <line x1="23" y1="11" x2="17" y2="11"></line> | ||||
|                     </svg> | ||||
|                     Registrieren | ||||
|                 </a> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </nav> | ||||
|  | ||||
|     {% block content %}{% endblock %} | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
		Reference in New Issue
	
	Block a user