multiuser/subscriptions/notifications
This commit is contained in:
		
							
								
								
									
										98
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										98
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,8 +1,94 @@ | |||||||
|  | # Byte-compiled / optimized / DLL files | ||||||
| __pycache__/ | __pycache__/ | ||||||
| *.pyc | *.py[cod] | ||||||
| .env | *$py.class | ||||||
| db.sqlite3 |  | ||||||
| .mypy_cache/ | # C extensions | ||||||
| .pytest_cache/ | *.so | ||||||
| .coverage |  | ||||||
|  | # 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" | name = "pypi" | ||||||
|  |  | ||||||
| [packages] | [packages] | ||||||
| django = "*" |  | ||||||
| django-environ = "*" | django-environ = "*" | ||||||
| djangorestframework = "*" | djangorestframework = "*" | ||||||
| requests = "*" | requests = "*" | ||||||
| python-dateutil = "*" | python-dateutil = "*" | ||||||
|  | django = "*" | ||||||
|  | jellyfin-apiclient-python = "*" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										192
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										192
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f" |             "sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @@ -34,101 +34,88 @@ | |||||||
|         }, |         }, | ||||||
|         "charset-normalizer": { |         "charset-normalizer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", |                 "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", | ||||||
|                 "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45", |                 "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", | ||||||
|                 "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", |                 "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", | ||||||
|                 "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", |                 "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", | ||||||
|                 "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", |                 "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", | ||||||
|                 "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", |                 "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", | ||||||
|                 "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d", |                 "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", | ||||||
|                 "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", |                 "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", | ||||||
|                 "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184", |                 "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", | ||||||
|                 "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", |                 "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", | ||||||
|                 "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b", |                 "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", | ||||||
|                 "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64", |                 "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", | ||||||
|                 "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", |                 "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", | ||||||
|                 "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", |                 "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", | ||||||
|                 "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", |                 "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", | ||||||
|                 "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344", |                 "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", | ||||||
|                 "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58", |                 "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", | ||||||
|                 "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", |                 "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", | ||||||
|                 "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", |                 "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", | ||||||
|                 "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", |                 "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", | ||||||
|                 "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", |                 "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", | ||||||
|                 "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", |                 "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", | ||||||
|                 "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", |                 "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", | ||||||
|                 "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", |                 "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", | ||||||
|                 "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", |                 "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", | ||||||
|                 "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1", |                 "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", | ||||||
|                 "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01", |                 "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", | ||||||
|                 "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", |                 "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", | ||||||
|                 "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58", |                 "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", | ||||||
|                 "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", |                 "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", | ||||||
|                 "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", |                 "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", | ||||||
|                 "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2", |                 "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", | ||||||
|                 "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a", |                 "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", | ||||||
|                 "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", |                 "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", | ||||||
|                 "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", |                 "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", | ||||||
|                 "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5", |                 "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", | ||||||
|                 "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb", |                 "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", | ||||||
|                 "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f", |                 "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", | ||||||
|                 "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", |                 "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", | ||||||
|                 "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", |                 "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", | ||||||
|                 "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", |                 "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", | ||||||
|                 "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", |                 "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", | ||||||
|                 "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7", |                 "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", | ||||||
|                 "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", |                 "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", | ||||||
|                 "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455", |                 "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", | ||||||
|                 "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", |                 "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", | ||||||
|                 "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4", |                 "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", | ||||||
|                 "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", |                 "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", | ||||||
|                 "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", |                 "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", | ||||||
|                 "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", |                 "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", | ||||||
|                 "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", |                 "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", | ||||||
|                 "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", |                 "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", | ||||||
|                 "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", |                 "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", | ||||||
|                 "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", |                 "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", | ||||||
|                 "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", |                 "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", | ||||||
|                 "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", |                 "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", | ||||||
|                 "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", |                 "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", | ||||||
|                 "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa", |                 "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", | ||||||
|                 "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", |                 "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", | ||||||
|                 "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", |                 "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", | ||||||
|                 "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", |                 "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", | ||||||
|                 "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", |                 "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", | ||||||
|                 "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", |                 "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", | ||||||
|                 "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", |                 "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", | ||||||
|                 "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02", |                 "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", | ||||||
|                 "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", |                 "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", | ||||||
|                 "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", |                 "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", | ||||||
|                 "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", |                 "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", | ||||||
|                 "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", |                 "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", | ||||||
|                 "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", |                 "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", | ||||||
|                 "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", |                 "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", | ||||||
|                 "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", |                 "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", | ||||||
|                 "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681", |                 "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", | ||||||
|                 "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", |                 "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", | ||||||
|                 "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", |                 "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", | ||||||
|                 "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a", |                 "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", | ||||||
|                 "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", |                 "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", | ||||||
|                 "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", |                 "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", | ||||||
|                 "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", |                 "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" | ||||||
|                 "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", |  | ||||||
|                 "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027", |  | ||||||
|                 "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", |  | ||||||
|                 "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", |  | ||||||
|                 "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", |  | ||||||
|                 "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", |  | ||||||
|                 "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", |  | ||||||
|                 "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", |  | ||||||
|                 "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da", |  | ||||||
|                 "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", |  | ||||||
|                 "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f", |  | ||||||
|                 "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", |  | ||||||
|                 "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f" |  | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.7'", |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==3.4.2" |             "version": "==3.4.3" | ||||||
|         }, |         }, | ||||||
|         "django": { |         "django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @@ -165,6 +152,15 @@ | |||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.10" |             "version": "==3.10" | ||||||
|         }, |         }, | ||||||
|  |         "jellyfin-apiclient-python": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:b666c8d175b36f2ce9e6020c13821eb1aa104a585bfbba58f6790fa15f358b40", | ||||||
|  |                 "sha256:f5e3dc4ea06a80d26859a62ace7c3ab26f762063a3032f9109f4cea2ed8ac5de" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|  |             "version": "==1.11.0" | ||||||
|  |         }, | ||||||
|         "python-dateutil": { |         "python-dateutil": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", |                 "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", | ||||||
| @@ -206,6 +202,14 @@ | |||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.9'", |             "markers": "python_version >= '3.9'", | ||||||
|             "version": "==2.5.0" |             "version": "==2.5.0" | ||||||
|  |         }, | ||||||
|  |         "websocket-client": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", | ||||||
|  |                 "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da" | ||||||
|  |             ], | ||||||
|  |             "markers": "python_version >= '3.8'", | ||||||
|  |             "version": "==1.8.0" | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
|     "develop": {} |     "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.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: | def _abs_url(base: str, p: str | None) -> str | None: | ||||||
|     if not p: |     if not p: | ||||||
|         return None |         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): | 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() |     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 False | ||||||
|  |  | ||||||
|     return [m for m in out if is_upcoming(m)] |     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> | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,25 @@ | |||||||
| from django.urls import path | 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 = [ | urlpatterns = [ | ||||||
|     path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"), |     path('', ArrIndexView.as_view(), name='index'), | ||||||
|     path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"), |      | ||||||
|  |     # 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 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.views import View | ||||||
| from django.contrib import messages | 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.views import APIView | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework import status | from rest_framework import status | ||||||
|  |  | ||||||
| from settingspanel.models import AppSettings | from settingspanel.models import AppSettings | ||||||
| from .services import sonarr_calendar, radarr_calendar, ArrServiceError | from .services import sonarr_calendar, radarr_calendar, ArrServiceError | ||||||
|  | from .models import SeriesSubscription, MovieSubscription | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_int(request, key, default): | def _get_int(request, key, default): | ||||||
| @@ -27,26 +32,26 @@ def _arr_conf_from_db(): | |||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| class SonarrAiringView(APIView): | #class SonarrAiringView(APIView): | ||||||
|     def get(self, request): | #    def get(self, request): | ||||||
|         days = _get_int(request, "days", 30) | #        days = _get_int(request, "days", 30) | ||||||
|         conf = _arr_conf_from_db() | #        conf = _arr_conf_from_db() | ||||||
|         try: | #        try: | ||||||
|             data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | #            data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"]) | ||||||
|             return Response({"count": len(data), "results": data}) | #            return Response({"count": len(data), "results": data}) | ||||||
|         except ArrServiceError as e: | #        except ArrServiceError as e: | ||||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | #            return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||||
|  |  | ||||||
|  |  | ||||||
| class RadarrUpcomingMoviesView(APIView): | #class RadarrUpcomingMoviesView(APIView): | ||||||
|     def get(self, request): | #    def get(self, request): | ||||||
|         days = _get_int(request, "days", 60) | #        days = _get_int(request, "days", 60) | ||||||
|         conf = _arr_conf_from_db() | #        conf = _arr_conf_from_db() | ||||||
|         try: | #        try: | ||||||
|             data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | #            data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"]) | ||||||
|             return Response({"count": len(data), "results": data}) | #            return Response({"count": len(data), "results": data}) | ||||||
|         except ArrServiceError as e: | #        except ArrServiceError as e: | ||||||
|             return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | #            return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArrIndexView(View): | class ArrIndexView(View): | ||||||
| @@ -75,10 +80,14 @@ class ArrIndexView(View): | |||||||
|             eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] |             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()] |             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 |         # Gruppierung nach Serie | ||||||
|         groups = defaultdict(lambda: { |         groups = defaultdict(lambda: { | ||||||
|             "seriesId": None, "seriesTitle": None, "seriesPoster": None, |             "seriesId": None, "seriesTitle": None, "seriesPoster": None, | ||||||
|             "seriesOverview": "", "seriesGenres": [], "episodes": [], |             "seriesOverview": "", "seriesGenres": [], "episodes": [], "is_subscribed": False, | ||||||
|         }) |         }) | ||||||
|         for e in eps: |         for e in eps: | ||||||
|             sid = e["seriesId"] |             sid = e["seriesId"] | ||||||
| @@ -101,8 +110,13 @@ class ArrIndexView(View): | |||||||
|         series_grouped = [] |         series_grouped = [] | ||||||
|         for g in groups.values(): |         for g in groups.values(): | ||||||
|             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) |             g["episodes"].sort(key=lambda x: (x["airDateUtc"] or "")) | ||||||
|  |             g["is_subscribed"] = g["seriesId"] in subscribed_series_ids | ||||||
|             series_grouped.append(g) |             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", { |         return render(request, "arr_api/index.html", { | ||||||
|             "query": q, |             "query": q, | ||||||
|             "kind": kind, |             "kind": kind, | ||||||
| @@ -112,3 +126,252 @@ class ArrIndexView(View): | |||||||
|             "series_grouped": series_grouped, |             "series_grouped": series_grouped, | ||||||
|             "movies": movies, |             "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"} | 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): | class ArrSettingsForm(forms.Form): | ||||||
|     sonarr_url     = forms.URLField(label="Sonarr URL", required=False, |     sonarr_url     = forms.URLField(label="Sonarr URL", required=False, | ||||||
|                                     widget=forms.URLInput(attrs=WIDE)) |                                     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-Pattern über feste ID | ||||||
|     singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False) |     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 |     # Arr | ||||||
|     sonarr_url = models.URLField(blank=True, null=True) |     sonarr_url = models.URLField(blank=True, null=True) | ||||||
|     sonarr_api_key = models.CharField(max_length=255, 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_port = models.PositiveIntegerField(blank=True, null=True) | ||||||
|     mail_secure = models.CharField( |     mail_secure = models.CharField( | ||||||
|         max_length=10, blank=True, null=True, |         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_user = models.CharField(max_length=255, blank=True, null=True) | ||||||
|     mail_password = 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 |     @classmethod | ||||||
|     def current(cls): |     def current(cls): | ||||||
|  |         """Get the current settings instance or create a new one""" | ||||||
|         obj, _ = cls.objects.get_or_create(singleton_id=1) |         obj, _ = cls.objects.get_or_create(singleton_id=1) | ||||||
|         return obj |         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 charset="utf-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> |     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||||
|     <title>Einstellungen – Subscribarr</title> |     <title>Einstellungen – Subscribarr</title> | ||||||
|     <style> |     <link rel="stylesheet" href="{% static 'css/settings.css' %}"> | ||||||
|         :root { |  | ||||||
|             --bg: #0b0b10; |  | ||||||
|             --panel: #12121a; |  | ||||||
|             --panel-b: #1f2030; |  | ||||||
|             --accent: #3b82f6; |  | ||||||
|             --muted: #9aa0b4; |  | ||||||
|             --text: #e6e6e6; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         * { |  | ||||||
|             box-sizing: border-box |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         body { |  | ||||||
|             background: var(--bg); |  | ||||||
|             color: var(--text); |  | ||||||
|             font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif; |  | ||||||
|             margin: 0 |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .wrap { |  | ||||||
|             max-width: 1000px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             padding: 16px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         a { |  | ||||||
|             color: #cfd3ea; |  | ||||||
|             text-decoration: none |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .topbar { |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             margin-bottom: 16px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .btn { |  | ||||||
|             padding: 10px 14px; |  | ||||||
|             border-radius: 10px; |  | ||||||
|             border: 1px solid #2a2a34; |  | ||||||
|             background: #111119; |  | ||||||
|             color: #fff; |  | ||||||
|             cursor: pointer |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .btn-primary { |  | ||||||
|             background: var(--accent); |  | ||||||
|             border-color: transparent |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .grid { |  | ||||||
|             display: grid; |  | ||||||
|             gap: 16px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         @media(min-width:900px) { |  | ||||||
|             .grid { |  | ||||||
|                 grid-template-columns: 1fr 1fr |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .card { |  | ||||||
|             background: var(--panel); |  | ||||||
|             border: 1px solid var(--panel-b); |  | ||||||
|             border-radius: 12px; |  | ||||||
|             padding: 14px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .card h2 { |  | ||||||
|             margin: 0 0 10px; |  | ||||||
|             font-size: 1.05rem |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .row { |  | ||||||
|             display: grid; |  | ||||||
|             grid-template-columns: 160px minmax(0, 1fr); |  | ||||||
|             /* <= statt 160px 1fr */ |  | ||||||
|             gap: 10px; |  | ||||||
|             align-items: center; |  | ||||||
|             margin-bottom: 10px; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .row label { |  | ||||||
|             color: #c9cbe3 |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .row input, |  | ||||||
|         .row select { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 10px 12px; |  | ||||||
|             border-radius: 10px; |  | ||||||
|             border: 1px solid #2a2a34; |  | ||||||
|             background: #111119; |  | ||||||
|             color: var(--text) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .help { |  | ||||||
|             color: var(--muted); |  | ||||||
|             font-size: .9rem |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .msgs { |  | ||||||
|             margin-bottom: 10px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .msg { |  | ||||||
|             background: #0f1425; |  | ||||||
|             border: 1px solid #283058; |  | ||||||
|             border-radius: 10px; |  | ||||||
|             padding: 10px; |  | ||||||
|             margin-bottom: 8px |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .input-wide { |  | ||||||
|             width: 100% !important; |  | ||||||
|             max-width: 100%; |  | ||||||
|             min-width: 0; |  | ||||||
|             display: block; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .row input, |  | ||||||
|         .row select, |  | ||||||
|         .row textarea { |  | ||||||
|             width: 100%; |  | ||||||
|             max-width: 100%; |  | ||||||
|             min-width: 0; |  | ||||||
|             display: block; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /* falls du passwort/URL Felder extra stylen willst, gleicher Fix */ |  | ||||||
|         .inline>input, |  | ||||||
|         .inline>.django-url, |  | ||||||
|         /* falls Widget eine Klasse rendert */ |  | ||||||
|         .inline>.django-password { |  | ||||||
|             min-width: 0; |  | ||||||
|             width: 100%; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .inline-actions { |  | ||||||
|             display: inline-flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 8px; |  | ||||||
|             min-width: 220px; |  | ||||||
|             justify-content: flex-end; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .btn { |  | ||||||
|             padding: 10px 14px; |  | ||||||
|             border-radius: 10px; |  | ||||||
|             border: 1px solid #2a2a34; |  | ||||||
|             background: #111119; |  | ||||||
|             color: #fff; |  | ||||||
|             cursor: pointer; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .btn:disabled { |  | ||||||
|             opacity: .6; |  | ||||||
|             cursor: default; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .badge { |  | ||||||
|             padding: 6px 10px; |  | ||||||
|             border-radius: 999px; |  | ||||||
|             font-size: .85rem; |  | ||||||
|             border: 1px solid #2a2a34; |  | ||||||
|             background: #111119; |  | ||||||
|             color: #cfd3ea; |  | ||||||
|             white-space: nowrap; |  | ||||||
|             /* eine Zeile */ |  | ||||||
|             max-width: 140px; |  | ||||||
|             /* begrenzt die Breite */ |  | ||||||
|             overflow: hidden; |  | ||||||
|             text-overflow: ellipsis; |  | ||||||
|             /* falls doch Text drin ist */ |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .badge.ok { |  | ||||||
|             border-color: #1f6f3a; |  | ||||||
|             background: #10331f; |  | ||||||
|             color: #a7e3bd; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .badge.err { |  | ||||||
|             border-color: #6f1f2a; |  | ||||||
|             background: #341016; |  | ||||||
|             color: #f1a3b0; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .badge.muted { |  | ||||||
|             opacity: .8; |  | ||||||
|         } |  | ||||||
|     </style> |  | ||||||
| </head> | </head> | ||||||
|  |  | ||||||
| <body> | <body> | ||||||
| @@ -218,6 +24,20 @@ | |||||||
|         <form method="post"> |         <form method="post"> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             <div class="grid"> |             <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"> |                 <div class="card"> | ||||||
|                     <h2>Sonarr & Radarr</h2> |                     <h2>Sonarr & Radarr</h2> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| from django.urls import path | from django.urls import path | ||||||
| from .views import SettingsView, test_connection | from .views import SettingsView, test_connection, first_run | ||||||
|  |  | ||||||
| app_name = "settingspanel" | app_name = "settingspanel" | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path("", SettingsView.as_view(), name="index"), |     path("", SettingsView.as_view(), name="index"), | ||||||
|     path("test-connection/", test_connection, name="test_connection"), |     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.shortcuts import render, redirect | ||||||
|  | from django.views import View | ||||||
| from django.contrib import messages | 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 .models import AppSettings | ||||||
| from django.http import JsonResponse | from django.http import JsonResponse | ||||||
|  | from accounts.utils import jellyfin_admin_required | ||||||
|  | from django.contrib.auth import get_user_model | ||||||
| import requests | 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): | def test_connection(request): | ||||||
|     kind = request.GET.get("kind")  # "sonarr" | "radarr" |     kind = request.GET.get("kind")  # "sonarr" | "radarr" | ||||||
|     url = (request.GET.get("url") or "").strip() |     url = (request.GET.get("url") or "").strip() | ||||||
| @@ -27,12 +69,17 @@ def test_connection(request): | |||||||
|     except requests.RequestException as e: |     except requests.RequestException as e: | ||||||
|         return JsonResponse({"ok": False, "error": str(e)}) |         return JsonResponse({"ok": False, "error": str(e)}) | ||||||
|  |  | ||||||
|  | @method_decorator(jellyfin_admin_required, name='dispatch') | ||||||
| class SettingsView(View): | class SettingsView(View): | ||||||
|     template_name = "settingspanel/settings.html" |     template_name = "settingspanel/settings.html" | ||||||
|  |  | ||||||
|     def get(self, request): |     def get(self, request): | ||||||
|         cfg = AppSettings.current() |         cfg = AppSettings.current() | ||||||
|         return render(request, self.template_name, { |         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={ |             "arr_form": ArrSettingsForm(initial={ | ||||||
|                 "sonarr_url": cfg.sonarr_url or "", |                 "sonarr_url": cfg.sonarr_url or "", | ||||||
|                 "sonarr_api_key": cfg.sonarr_api_key or "", |                 "sonarr_api_key": cfg.sonarr_api_key or "", | ||||||
| @@ -54,15 +101,24 @@ class SettingsView(View): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|     def post(self, request): |     def post(self, request): | ||||||
|         arr_form  = ArrSettingsForm(request.POST) |         jellyfin_form = JellyfinSettingsForm(request.POST) | ||||||
|  |         arr_form = ArrSettingsForm(request.POST) | ||||||
|         mail_form = MailSettingsForm(request.POST) |         mail_form = MailSettingsForm(request.POST) | ||||||
|         acc_form  = AccountForm(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, { |             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() |         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_url     = arr_form.cleaned_data["sonarr_url"] or None | ||||||
|         cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None |         cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None | ||||||
|         cfg.radarr_url     = arr_form.cleaned_data["radarr_url"] or None |         cfg.radarr_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 | from pathlib import Path | ||||||
|  | import os | ||||||
|  |  | ||||||
| # Build paths inside the project like this: BASE_DIR / 'subdir'. | # Build paths inside the project like this: BASE_DIR / 'subdir'. | ||||||
| BASE_DIR = Path(__file__).resolve().parent.parent | 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/ | # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ | ||||||
|  |  | ||||||
| # SECURITY WARNING: keep the secret key used in production secret! | # 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! | # 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 | # Application definition | ||||||
|  |  | ||||||
| INSTALLED_APPS = [ | INSTALLED_APPS = [ | ||||||
|     'django.contrib.admin', |  | ||||||
|     'django.contrib.auth', |     'django.contrib.auth', | ||||||
|     'django.contrib.contenttypes', |     'django.contrib.contenttypes', | ||||||
|     'django.contrib.sessions', |     'django.contrib.sessions', | ||||||
| @@ -40,6 +40,7 @@ INSTALLED_APPS = [ | |||||||
|     'rest_framework', |     'rest_framework', | ||||||
|     'arr_api', |     'arr_api', | ||||||
|     'settingspanel', |     'settingspanel', | ||||||
|  |     'accounts', | ||||||
| ] | ] | ||||||
|  |  | ||||||
| MIDDLEWARE = [ | MIDDLEWARE = [ | ||||||
| @@ -50,6 +51,7 @@ MIDDLEWARE = [ | |||||||
|     'django.contrib.auth.middleware.AuthenticationMiddleware', |     'django.contrib.auth.middleware.AuthenticationMiddleware', | ||||||
|     'django.contrib.messages.middleware.MessageMiddleware', |     'django.contrib.messages.middleware.MessageMiddleware', | ||||||
|     'django.middleware.clickjacking.XFrameOptionsMiddleware', |     'django.middleware.clickjacking.XFrameOptionsMiddleware', | ||||||
|  |     'settingspanel.middleware.SetupMiddleware', | ||||||
| ] | ] | ||||||
|  |  | ||||||
| ROOT_URLCONF = 'subscribarr.urls' | ROOT_URLCONF = 'subscribarr.urls' | ||||||
| @@ -57,7 +59,7 @@ ROOT_URLCONF = 'subscribarr.urls' | |||||||
| TEMPLATES = [ | TEMPLATES = [ | ||||||
|     { |     { | ||||||
|         'BACKEND': 'django.template.backends.django.DjangoTemplates', |         'BACKEND': 'django.template.backends.django.DjangoTemplates', | ||||||
|         'DIRS': [], |         'DIRS': [BASE_DIR / 'templates'], | ||||||
|         'APP_DIRS': True, |         'APP_DIRS': True, | ||||||
|         'OPTIONS': { |         'OPTIONS': { | ||||||
|             'context_processors': [ |             'context_processors': [ | ||||||
| @@ -75,10 +77,11 @@ WSGI_APPLICATION = 'subscribarr.wsgi.application' | |||||||
| # Database | # Database | ||||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#databases | # https://docs.djangoproject.com/en/5.2/ref/settings/#databases | ||||||
|  |  | ||||||
|  | _db_path = os.getenv('DB_PATH') | ||||||
| DATABASES = { | DATABASES = { | ||||||
|     'default': { |     'default': { | ||||||
|         'ENGINE': 'django.db.backends.sqlite3', |         '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 | # Internationalization | ||||||
| # https://docs.djangoproject.com/en/5.2/topics/i18n/ | # 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 | USE_I18N = True | ||||||
|  |  | ||||||
| @@ -118,8 +121,39 @@ USE_TZ = True | |||||||
| # https://docs.djangoproject.com/en/5.2/howto/static-files/ | # https://docs.djangoproject.com/en/5.2/howto/static-files/ | ||||||
|  |  | ||||||
| STATIC_URL = 'static/' | 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 | # Default primary key field type | ||||||
| # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field | # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field | ||||||
|  |  | ||||||
| DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | 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 |     1. Import the include() function: from django.urls import include, path | ||||||
|     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) |     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) | ||||||
| """ | """ | ||||||
| from django.contrib import admin |  | ||||||
| from django.urls import path, include | from django.urls import path, include | ||||||
| from arr_api.views import ArrIndexView |  | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path('admin/', admin.site.urls), |     path('', include('arr_api.urls')), | ||||||
|     path("", ArrIndexView.as_view(), name="home"), |     path('settings/', include('settingspanel.urls')), | ||||||
|     path("settings/", include("settingspanel.urls")), |     path('accounts/', include('accounts.urls', namespace='accounts')), | ||||||
|     path("api/", include("arr_api.urls")), |  | ||||||
| ] | ] | ||||||
|   | |||||||
							
								
								
									
										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