From fb0c7da252d28eb213e4661f6426df23c3af13a6 Mon Sep 17 00:00:00 2001 From: jschaufuss Date: Sun, 10 Aug 2025 17:48:15 +0200 Subject: [PATCH] multiuser/subscriptions/notifications --- .gitignore | 98 +- Dockerfile | 45 + Pipfile | 3 +- Pipfile.lock | 192 +-- accounts/__init__.py | 0 accounts/admin.py | 3 + accounts/apps.py | 6 + accounts/forms.py | 24 + accounts/migrations/0001_initial.py | 45 + ...fin_server_user_jellyfin_token_and_more.py | 28 + accounts/migrations/__init__.py | 0 accounts/models.py | 39 + accounts/templates/accounts/login.html | 15 + .../templates/accounts/password_change.html | 12 + .../accounts/password_change_done.html | 9 + accounts/templates/accounts/profile.html | 97 ++ accounts/templates/accounts/register.html | 15 + accounts/tests.py | 3 + accounts/urls.py | 19 + accounts/utils.py | 117 ++ accounts/views.py | 132 +++ .../management/commands/check_new_media.py | 14 + arr_api/migrations/0001_initial.py | 52 + arr_api/migrations/0002_sentnotification.py | 32 + arr_api/models.py | 52 +- arr_api/notifications.py | 356 ++++++ arr_api/services.py | 61 +- .../arr_api/email/new_media_notification.html | 111 ++ arr_api/templates/arr_api/index.html | 1044 ++++++----------- arr_api/urls.py | 24 +- arr_api/views.py | 305 ++++- docker-compose.yml | 39 + docker/entrypoint.sh | 88 ++ settingspanel/forms.py | 52 + settingspanel/middleware.py | 23 + ...2_appsettings_jellyfin_api_key_and_more.py | 23 + settingspanel/models.py | 21 +- .../templates/settingspanel/first_run.html | 57 + .../templates/settingspanel/settings.html | 210 +--- settingspanel/urls.py | 3 +- settingspanel/views.py | 68 +- static/css/base.css | 160 +++ static/css/index.css | 477 ++++++++ static/css/profile.css | 155 +++ static/css/settings.css | 178 +++ static/css/setup.css | 71 ++ subscribarr/settings.py | 50 +- subscribarr/urls.py | 9 +- templates/base.html | 73 ++ 49 files changed, 3676 insertions(+), 1034 deletions(-) create mode 100644 Dockerfile create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/apps.py create mode 100644 accounts/forms.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/0002_user_jellyfin_server_user_jellyfin_token_and_more.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/templates/accounts/login.html create mode 100644 accounts/templates/accounts/password_change.html create mode 100644 accounts/templates/accounts/password_change_done.html create mode 100644 accounts/templates/accounts/profile.html create mode 100644 accounts/templates/accounts/register.html create mode 100644 accounts/tests.py create mode 100644 accounts/urls.py create mode 100644 accounts/utils.py create mode 100644 accounts/views.py create mode 100644 arr_api/management/commands/check_new_media.py create mode 100644 arr_api/migrations/0001_initial.py create mode 100644 arr_api/migrations/0002_sentnotification.py create mode 100644 arr_api/notifications.py create mode 100644 arr_api/templates/arr_api/email/new_media_notification.html create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 settingspanel/middleware.py create mode 100644 settingspanel/migrations/0002_appsettings_jellyfin_api_key_and_more.py create mode 100644 settingspanel/templates/settingspanel/first_run.html create mode 100644 static/css/base.css create mode 100644 static/css/index.css create mode 100644 static/css/profile.css create mode 100644 static/css/settings.css create mode 100644 static/css/setup.css create mode 100644 templates/base.html diff --git a/.gitignore b/.gitignore index 6354ab9..46c76b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,94 @@ +# Byte-compiled / optimized / DLL files __pycache__/ -*.pyc -.env -db.sqlite3 -.mypy_cache/ -.pytest_cache/ -.coverage +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django +*.log +local_settings.py +staticfiles/ +media/ + +# Database +/db.sqlite3 + +# Environment files +.env.local +.env.*.local + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDEs / Editors +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Project specific +Subscribarr/cookies.txt +ssh-config/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..815e7dc --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Pipfile b/Pipfile index c3b2ee7..084e0a1 100644 --- a/Pipfile +++ b/Pipfile @@ -4,11 +4,12 @@ verify_ssl = true name = "pypi" [packages] -django = "*" django-environ = "*" djangorestframework = "*" requests = "*" python-dateutil = "*" +django = "*" +jellyfin-apiclient-python = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index f83d154..cff858e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f" + "sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49" }, "pipfile-spec": 6, "requires": { @@ -34,101 +34,88 @@ }, "charset-normalizer": { "hashes": [ - "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", - "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45", - "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", - "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", - "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", - "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", - "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d", - "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", - "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184", - "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", - "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b", - "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64", - "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", - "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", - "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", - "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344", - "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58", - "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", - "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", - "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", - "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", - "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", - "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", - "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", - "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", - "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1", - "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01", - "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", - "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58", - "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", - "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", - "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2", - "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a", - "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", - "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", - "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5", - "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb", - "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f", - "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", - "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", - "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", - "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", - "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7", - "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", - "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455", - "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", - "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4", - "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", - "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", - "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", - "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", - "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", - "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", - "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", - "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", - "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", - "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", - "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa", - "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", - "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", - "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", - "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", - "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", - "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", - "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02", - "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", - "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", - "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", - "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", - "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", - "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", - "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", - "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681", - "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", - "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", - "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a", - "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", - "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", - "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", - "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", - "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027", - "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", - "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", - "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", - "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", - "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", - "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", - "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da", - "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", - "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f", - "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", - "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f" + "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", + "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", + "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", + "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", + "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", + "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", + "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", + "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", + "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", + "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", + "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", + "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", + "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", + "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", + "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", + "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", + "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", + "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", + "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", + "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", + "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", + "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", + "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", + "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", + "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", + "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", + "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", + "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", + "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", + "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", + "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", + "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", + "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", + "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", + "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", + "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", + "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", + "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", + "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", + "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", + "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", + "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", + "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", + "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", + "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", + "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", + "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", + "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", + "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", + "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", + "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", + "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", + "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", + "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", + "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", + "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", + "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", + "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", + "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", + "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", + "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", + "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", + "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", + "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", + "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", + "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", + "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", + "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", + "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", + "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", + "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", + "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", + "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", + "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", + "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", + "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", + "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", + "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", + "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" ], "markers": "python_version >= '3.7'", - "version": "==3.4.2" + "version": "==3.4.3" }, "django": { "hashes": [ @@ -165,6 +152,15 @@ "markers": "python_version >= '3.6'", "version": "==3.10" }, + "jellyfin-apiclient-python": { + "hashes": [ + "sha256:b666c8d175b36f2ce9e6020c13821eb1aa104a585bfbba58f6790fa15f358b40", + "sha256:f5e3dc4ea06a80d26859a62ace7c3ab26f762063a3032f9109f4cea2ed8ac5de" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==1.11.0" + }, "python-dateutil": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", @@ -206,6 +202,14 @@ ], "markers": "python_version >= '3.9'", "version": "==2.5.0" + }, + "websocket-client": { + "hashes": [ + "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", + "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da" + ], + "markers": "python_version >= '3.8'", + "version": "==1.8.0" } }, "develop": {} diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..1e71180 --- /dev/null +++ b/accounts/forms.py @@ -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'})) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..4821058 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/accounts/migrations/0002_user_jellyfin_server_user_jellyfin_token_and_more.py b/accounts/migrations/0002_user_jellyfin_server_user_jellyfin_token_and_more.py new file mode 100644 index 0000000..ab5cd31 --- /dev/null +++ b/accounts/migrations/0002_user_jellyfin_server_user_jellyfin_token_and_more.py @@ -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), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..f80eb9a --- /dev/null +++ b/accounts/models.py @@ -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") diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..996abd9 --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block content %} +
+

Anmelden

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/password_change.html b/accounts/templates/accounts/password_change.html new file mode 100644 index 0000000..3901230 --- /dev/null +++ b/accounts/templates/accounts/password_change.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} +
+

Passwort ändern

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/password_change_done.html b/accounts/templates/accounts/password_change_done.html new file mode 100644 index 0000000..7d3137a --- /dev/null +++ b/accounts/templates/accounts/password_change_done.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} +
+

Passwort geändert

+

Ihr Passwort wurde erfolgreich geändert.

+

Zurück zum Profil

+
+{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..6bf9950 --- /dev/null +++ b/accounts/templates/accounts/profile.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% load static %} + +{% block extra_style %} + +{% endblock %} + +{% block content %} +
+

Hallo, {{ user.username }}

+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+

E-Mail-Adresse

+
+ {% csrf_token %} +
+ + {{ form.email }} +
+ +
+ + {% if user.jellyfin_server %} +
+

Jellyfin-Verbindung

+

+ Server: {{ user.jellyfin_server }}
+ Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}
+ {% if user.is_jellyfin_admin %} + Jellyfin Administrator + {% endif %} +

+
+ {% endif %} +
+ +
+

Meine Abonnements

+ +

Serien

+ {% if series_subs %} +
+ {% for sub in series_subs %} +
+ {% if sub.series_poster %} + {{ sub.series_title }} + {% else %} + + {% endif %} +
+
{{ sub.series_title }}
+
Abonniert am {{ sub.created_at|date:"d.m.Y" }}
+ {% if sub.series_overview %} +
{{ sub.series_overview|truncatechars:100 }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Keine Serien abonniert.

+ {% endif %} + +

Filme

+ {% if movie_subs %} +
+ {% for sub in movie_subs %} +
+ {% if sub.poster %} + {{ sub.title }} + {% else %} + + {% endif %} +
+
{{ sub.title }}
+
Abonniert am {{ sub.created_at|date:"d.m.Y" }}
+ {% if sub.overview %} +
{{ sub.overview|truncatechars:100 }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

Keine Filme abonniert.

+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/register.html b/accounts/templates/accounts/register.html new file mode 100644 index 0000000..2216734 --- /dev/null +++ b/accounts/templates/accounts/register.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block content %} +
+

Registrieren

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..4428075 --- /dev/null +++ b/accounts/urls.py @@ -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'), +] diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..a7c7dab --- /dev/null +++ b/accounts/utils.py @@ -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 diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..ba5515d --- /dev/null +++ b/accounts/views.py @@ -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}) diff --git a/arr_api/management/commands/check_new_media.py b/arr_api/management/commands/check_new_media.py new file mode 100644 index 0000000..c3eca81 --- /dev/null +++ b/arr_api/management/commands/check_new_media.py @@ -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)}')) diff --git a/arr_api/migrations/0001_initial.py b/arr_api/migrations/0001_initial.py new file mode 100644 index 0000000..6faaaa4 --- /dev/null +++ b/arr_api/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/arr_api/migrations/0002_sentnotification.py b/arr_api/migrations/0002_sentnotification.py new file mode 100644 index 0000000..0296294 --- /dev/null +++ b/arr_api/migrations/0002_sentnotification.py @@ -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')}, + }, + ), + ] diff --git a/arr_api/models.py b/arr_api/models.py index 71a8362..8d731cb 100644 --- a/arr_api/models.py +++ b/arr_api/models.py @@ -1,3 +1,53 @@ from django.db import models +from django.conf import settings -# Create your models here. +class SeriesSubscription(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='series_subscriptions') + series_id = models.IntegerField() + series_title = models.CharField(max_length=255) + series_poster = models.URLField(null=True, blank=True) + series_overview = models.TextField(blank=True) + series_genres = models.JSONField(default=list) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren + + def __str__(self): + return self.series_title + +class MovieSubscription(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='movie_subscriptions') + movie_id = models.IntegerField() + title = models.CharField(max_length=255) + poster = models.URLField(null=True, blank=True) + overview = models.TextField(blank=True) + genres = models.JSONField(default=list) + release_date = models.DateTimeField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren + + def __str__(self): + return self.title + +class SentNotification(models.Model): + """ + Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + media_id = models.IntegerField() + media_type = models.CharField(max_length=10) # 'series' oder 'movie' + media_title = models.CharField(max_length=255) + air_date = models.DateField() + sent_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['user', 'media_id', 'media_type', 'air_date'] + ordering = ['-sent_at'] + + def __str__(self): + return f"{self.media_type}: {self.media_title} for {self.user.username} on {self.air_date}" diff --git a/arr_api/notifications.py b/arr_api/notifications.py new file mode 100644 index 0000000..4c90b08 --- /dev/null +++ b/arr_api/notifications.py @@ -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 diff --git a/arr_api/services.py b/arr_api/services.py index c3dd113..7ef3502 100644 --- a/arr_api/services.py +++ b/arr_api/services.py @@ -25,7 +25,7 @@ def _get(url, headers, params=None, timeout=5): def _abs_url(base: str, p: str | None) -> str | None: if not p: return None - return f"{base.rstrip('/')}{p}" if p.startswith("/") else p + return f"{base.rstrip('/')}" + p if p.startswith("/") else p def sonarr_calendar(days: int | None = None, base_url: str | None = None, api_key: str | None = None): base = (base_url or ENV_SONARR_URL).strip() @@ -131,3 +131,62 @@ def radarr_calendar(days: int | None = None, base_url: str | None = None, api_ke return False return [m for m in out if is_upcoming(m)] + +def sonarr_get_series(series_id: int, base_url: str | None = None, api_key: str | None = None) -> dict | None: + """Fetch a single series by id from Sonarr, return dict with title, overview, poster and genres.""" + base = (base_url or ENV_SONARR_URL).strip() + key = (api_key or ENV_SONARR_KEY).strip() + if not base or not key: + return None + url = f"{base.rstrip('/')}/api/v3/series/{series_id}" + headers = {"X-Api-Key": key} + data = _get(url, headers) + # Poster + poster = None + for img in (data.get("images") or []): + if (img.get("coverType") or "").lower() == "poster": + poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) + if poster: + break + return { + "series_id": data.get("id"), + "series_title": data.get("title"), + "series_overview": data.get("overview") or "", + "series_genres": data.get("genres") or [], + "series_poster": poster, + } + +def radarr_lookup_movie_by_title(title: str, base_url: str | None = None, api_key: str | None = None) -> dict | None: + """Lookup a movie by title via Radarr /api/v3/movie/lookup. Returns title, poster, overview, genres, year, tmdbId, and id if present.""" + base = (base_url or ENV_RADARR_URL).strip() + key = (api_key or ENV_RADARR_KEY).strip() + if not base or not key or not title: + return None + url = f"{base.rstrip('/')}/api/v3/movie/lookup" + headers = {"X-Api-Key": key} + data = _get(url, headers, params={"term": title}) + if not data: + return None + # naive pick: exact match by title (case-insensitive), else first + best = None + for it in data: + if (it.get("title") or "").lower() == title.lower(): + best = it + break + if not best: + best = data[0] + poster = None + for img in (best.get("images") or []): + if (img.get("coverType") or "").lower() == "poster": + poster = img.get("remoteUrl") or _abs_url(base, img.get("url")) + if poster: + break + return { + "movie_id": best.get("id") or 0, + "title": best.get("title") or title, + "poster": poster, + "overview": best.get("overview") or "", + "genres": best.get("genres") or [], + "year": best.get("year"), + "tmdbId": best.get("tmdbId"), + } diff --git a/arr_api/templates/arr_api/email/new_media_notification.html b/arr_api/templates/arr_api/email/new_media_notification.html new file mode 100644 index 0000000..a5cdad1 --- /dev/null +++ b/arr_api/templates/arr_api/email/new_media_notification.html @@ -0,0 +1,111 @@ + + + + + + + + + +
+

Neue {{ type }} verfügbar!

+
+
+ {% if poster_url %} + Poster + {% else %} +
+ {% endif %} + +
+

Hallo {{ username }},

+ +

{{ title }}

+ + {% if episode_title %} +

Episode: {{ episode_title }}

+ {% endif %} + {% if season and episode %} +

S{{ season }}E{{ episode }}

+ {% endif %} + + {% if year %} +

Jahr: {{ year }}

+ {% endif %} + {% if release_type %} +

Release: {{ release_type }}

+ {% endif %} + + {% if air_date %} +

Veröffentlicht am: {{ air_date }}

+ {% endif %} + + {% if overview %} +

{{ overview }}

+ {% endif %} + +

Du kannst das jetzt auf Jellyfin anschauen.

+
+
+ + + \ No newline at end of file diff --git a/arr_api/templates/arr_api/index.html b/arr_api/templates/arr_api/index.html index e223d3e..2404439 100644 --- a/arr_api/templates/arr_api/index.html +++ b/arr_api/templates/arr_api/index.html @@ -1,738 +1,402 @@ - - +{% extends "base.html" %} +{% load static %} - - - - Subscribarr – Übersicht - - - - - -
-
-
-
- kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }} -
- - ⚙️ Einstellungen - -
+
- -
-

Subscribarr

- -
-
- - - - -
- - -
- - {% if show_series %} -
-

Laufende Serien

-
- {% for s in series_grouped %} -
-
- {% if s.seriesPoster %} - {{ s.seriesTitle }} - {% else %} - - {% endif %} -
-
-
{{ s.seriesTitle }}
-
- {% for e in s.episodes %} -
- S{{ e.seasonNumber }}E{{ e.episodeNumber }} — {{ e.title|default:"(tba)" }}
- -
- {% empty %} -
Keine kommenden Episoden.
- {% endfor %} -
-
- {# sichere Episoden-JSON für Modal #} - {% with sid=s.seriesId|stringformat:"s" %} - {% with eid="eps-"|add:sid %} - {{ s.episodes|json_script:eid }} - {% endwith %} - {% endwith %} -
- {% empty %} -

Keine Serien gefunden.

- {% endfor %} -
-
- {% endif %} - - {% if show_movies %} -
-

Anstehende Filme

-
- {% for m in movies %} -
- {% if m.posterUrl %} - {{ m.title }} + {% if show_series %} +
+

Laufende Serien

+
+ {% for s in series_grouped %} +
+
+ {% if s.seriesPoster %} + {{ s.seriesTitle }} {% else %} - + {% endif %} -
{{ m.title }}{% if m.year %} ({{ m.year }}){% endif %}
-
- {% if m.inCinemas %}Kino: {% endif %} - {% if m.digitalRelease %}
Digital: {% endif %} - {% if m.physicalRelease %}
Disc: {% endif %} +
+
+
{{ s.seriesTitle }}
+
+ {% for e in s.episodes %} +
+ S{{ e.seasonNumber }}E{{ e.episodeNumber }} — {{ e.title|default:"(tba)" }}
+ +
+ {% empty %} +
Keine kommenden Episoden.
+ {% endfor %}
- {% empty %} -

Keine Filme gefunden.

- {% endfor %} + {# sichere Episoden-JSON für Modal #} + {% with sid=s.seriesId|stringformat:"s" %} + {% with eid="eps-"|add:sid %} + {{ s.episodes|json_script:eid }} + {% endwith %} + {% endwith %}
+ {% empty %} +

Keine Serien gefunden.

+ {% endfor %}
- {% endif %}
+ {% endif %} - -