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 %}
+
+{% 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
+
+
+{% 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 %}
+
+{% 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
+
+
+ {% 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 %}
+

+ {% 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 %}
+

+ {% 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 %}
+
+{% 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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {% if poster_url %}
+

+ {% 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 %}
-

- {% else %}
-

- {% endif %}
-
-
- {# 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 %}
-

+ {% if show_series %}
+
+
Laufende Serien
+
+ {% for s in series_grouped %}
+
+
+ {% if s.seriesPoster %}
+

{% 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 %}
+
+
- {% 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 %}
-
-