multiuser/subscriptions/notifications

This commit is contained in:
2025-08-10 17:48:15 +02:00
parent d4b811dbad
commit fb0c7da252
49 changed files with 3676 additions and 1034 deletions

98
.gitignore vendored
View File

@@ -1,8 +1,94 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.pyc
.env
db.sqlite3
.mypy_cache/
.pytest_cache/
.coverage
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django
*.log
local_settings.py
staticfiles/
media/
# Database
/db.sqlite3
# Environment files
.env.local
.env.*.local
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# IDEs / Editors
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Project specific
Subscribarr/cookies.txt
ssh-config/

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps (include cron)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
cron \
&& rm -rf /var/lib/apt/lists/*
# Copy project
COPY Pipfile Pipfile.lock /app/
RUN pip install pipenv && PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy --system
COPY . /app
# Optional non-root user (not used as default to allow cron)
RUN useradd -ms /bin/bash app && chown -R app:app /app
# USER app # keep running as root to manage cron
# Runtime env defaults
ENV DJANGO_DEBUG=true \
DJANGO_ALLOWED_HOSTS=* \
DB_PATH=/app/data/db.sqlite3 \
NOTIFICATIONS_ALLOW_DUPLICATES=false \
CRON_SCHEDULE="*/30 * * * *" \
ADMIN_USERNAME= \
ADMIN_PASSWORD= \
ADMIN_EMAIL=
# create data dir for sqlite
RUN mkdir -p /app/data
# Entrypoint script
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
CMD ["/entrypoint.sh"]

View File

@@ -4,11 +4,12 @@ verify_ssl = true
name = "pypi"
[packages]
django = "*"
django-environ = "*"
djangorestframework = "*"
requests = "*"
python-dateutil = "*"
django = "*"
jellyfin-apiclient-python = "*"
[dev-packages]

192
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "e02bde8a8f8e5abfb9e7b093ecd81b4f54bc5af7756be42987f3ee1171bd228f"
"sha256": "cea8d420f44ecbfb792b63223f9b9827af98bdff685d256b47053575dfb15b49"
},
"pipfile-spec": 6,
"requires": {
@@ -34,101 +34,88 @@
},
"charset-normalizer": {
"hashes": [
"sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4",
"sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45",
"sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7",
"sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0",
"sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7",
"sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d",
"sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d",
"sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0",
"sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184",
"sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db",
"sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b",
"sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64",
"sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b",
"sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8",
"sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff",
"sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344",
"sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58",
"sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e",
"sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471",
"sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148",
"sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a",
"sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836",
"sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e",
"sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63",
"sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c",
"sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1",
"sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01",
"sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366",
"sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58",
"sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5",
"sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c",
"sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2",
"sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a",
"sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597",
"sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b",
"sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5",
"sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb",
"sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f",
"sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0",
"sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941",
"sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0",
"sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86",
"sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7",
"sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7",
"sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455",
"sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6",
"sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4",
"sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0",
"sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3",
"sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1",
"sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6",
"sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981",
"sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c",
"sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980",
"sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645",
"sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7",
"sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12",
"sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa",
"sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd",
"sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef",
"sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f",
"sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2",
"sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d",
"sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5",
"sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02",
"sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3",
"sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd",
"sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e",
"sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214",
"sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd",
"sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a",
"sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c",
"sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681",
"sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba",
"sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f",
"sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a",
"sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28",
"sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691",
"sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82",
"sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a",
"sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027",
"sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7",
"sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518",
"sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf",
"sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b",
"sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9",
"sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544",
"sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da",
"sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509",
"sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f",
"sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a",
"sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"
"sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91",
"sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0",
"sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154",
"sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601",
"sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884",
"sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07",
"sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c",
"sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64",
"sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe",
"sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f",
"sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432",
"sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc",
"sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa",
"sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9",
"sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae",
"sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19",
"sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d",
"sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e",
"sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4",
"sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7",
"sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312",
"sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92",
"sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31",
"sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c",
"sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f",
"sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99",
"sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b",
"sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15",
"sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392",
"sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f",
"sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8",
"sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491",
"sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0",
"sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc",
"sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0",
"sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f",
"sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a",
"sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40",
"sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927",
"sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849",
"sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce",
"sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14",
"sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05",
"sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c",
"sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c",
"sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a",
"sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc",
"sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34",
"sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9",
"sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096",
"sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14",
"sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30",
"sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b",
"sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b",
"sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942",
"sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db",
"sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5",
"sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b",
"sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce",
"sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669",
"sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0",
"sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018",
"sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93",
"sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe",
"sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049",
"sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a",
"sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef",
"sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2",
"sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca",
"sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16",
"sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f",
"sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb",
"sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1",
"sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557",
"sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37",
"sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7",
"sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72",
"sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c",
"sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.2"
"version": "==3.4.3"
},
"django": {
"hashes": [
@@ -165,6 +152,15 @@
"markers": "python_version >= '3.6'",
"version": "==3.10"
},
"jellyfin-apiclient-python": {
"hashes": [
"sha256:b666c8d175b36f2ce9e6020c13821eb1aa104a585bfbba58f6790fa15f358b40",
"sha256:f5e3dc4ea06a80d26859a62ace7c3ab26f762063a3032f9109f4cea2ed8ac5de"
],
"index": "pypi",
"markers": "python_version >= '3.6'",
"version": "==1.11.0"
},
"python-dateutil": {
"hashes": [
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
@@ -206,6 +202,14 @@
],
"markers": "python_version >= '3.9'",
"version": "==2.5.0"
},
"websocket-client": {
"hashes": [
"sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526",
"sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"
],
"markers": "python_version >= '3.8'",
"version": "==1.8.0"
}
},
"develop": {}

0
accounts/__init__.py Normal file
View File

3
accounts/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

24
accounts/forms.py Normal file
View File

@@ -0,0 +1,24 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User
class CustomUserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ('username', 'email', 'password1', 'password2')
class CustomUserChangeForm(UserChangeForm):
password = None # Passwort-Änderung über extra Formular
class Meta:
model = User
fields = ('email',)
widgets = {
'email': forms.EmailInput(attrs={'class': 'text-input', 'placeholder': 'E-Mail-Adresse'}),
}
class JellyfinLoginForm(forms.Form):
username = forms.CharField(label='Benutzername', widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='Passwort', widget=forms.PasswordInput(attrs={'class': 'form-control'}))

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.5 on 2025-08-10 11:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('bio', models.TextField(blank=True, max_length=500)),
('is_admin', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@@ -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),
),
]

View File

39
accounts/models.py Normal file
View File

@@ -0,0 +1,39 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
"""
Custom User Model mit zusätzlichen Feldern und Berechtigungen.
Normale User können nur ihre eigenen Daten bearbeiten.
Admin-User können alles.
"""
email = models.EmailField(_("email address"), unique=True)
bio = models.TextField(max_length=500, blank=True)
is_admin = models.BooleanField(default=False)
# Jellyfin fields
jellyfin_user_id = models.CharField(max_length=100, blank=True, null=True)
jellyfin_token = models.CharField(max_length=500, blank=True, null=True)
jellyfin_server = models.CharField(max_length=200, blank=True, null=True)
def check_jellyfin_admin(self):
"""Check if user is Jellyfin admin on the server"""
from accounts.utils import JellyfinClient
if not self.jellyfin_user_id or not self.jellyfin_token:
return False
try:
client = JellyfinClient()
return client.is_admin(self.jellyfin_user_id, self.jellyfin_token)
except:
# Im Fehlerfall den lokalen Status verwenden
return self.is_admin
@property
def is_jellyfin_admin(self):
"""Check if user is admin either locally or on Jellyfin server"""
return self.is_admin or self.check_jellyfin_admin()
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Anmelden</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Anmelden</button>
</form>
<div class="auth-links">
<p>Noch kein Konto? <a href="{% url 'accounts:register' %}">Jetzt registrieren</a></p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Passwort ändern</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Passwort ändern</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Passwort geändert</h2>
<p>Ihr Passwort wurde erfolgreich geändert.</p>
<p><a href="{% url 'accounts:profile' %}">Zurück zum Profil</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% load static %}
{% block extra_style %}
<link rel="stylesheet" href="{% static 'css/profile.css' %}">
{% endblock %}
{% block content %}
<div class="profile-container">
<h2>Hallo, {{ user.username }}</h2>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="profile-section">
<h3>E-Mail-Adresse</h3>
<form method="post" class="profile-form compact-form">
{% csrf_token %}
<div class="form-row">
<label for="id_email">E-Mail</label>
{{ form.email }}
</div>
<button type="submit" class="btn-primary">Speichern</button>
</form>
{% if user.jellyfin_server %}
<div class="jellyfin-info">
<h4>Jellyfin-Verbindung</h4>
<p>
Server: {{ user.jellyfin_server }}<br>
Status: {% if user.jellyfin_token %}Verbunden{% else %}Nicht verbunden{% endif %}<br>
{% if user.is_jellyfin_admin %}
<span class="badge badge-admin">Jellyfin Administrator</span>
{% endif %}
</p>
</div>
{% endif %}
</div>
<div class="profile-section">
<h3>Meine Abonnements</h3>
<h4>Serien</h4>
{% if series_subs %}
<div class="subscription-list">
{% for sub in series_subs %}
<div class="subscription-item">
{% if sub.series_poster %}
<img src="{{ sub.series_poster }}" alt="{{ sub.series_title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.series_title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.series_overview %}
<div class="subscription-overview">{{ sub.series_overview|truncatechars:100 }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Serien abonniert.</p>
{% endif %}
<h4>Filme</h4>
{% if movie_subs %}
<div class="subscription-list">
{% for sub in movie_subs %}
<div class="subscription-item">
{% if sub.poster %}
<img src="{{ sub.poster }}" alt="{{ sub.title }}" class="subscription-poster">
{% else %}
<img src="https://via.placeholder.com/80x120?text=Kein+Poster" alt="" class="subscription-poster">
{% endif %}
<div class="subscription-info">
<div class="subscription-title">{{ sub.title }}</div>
<div class="subscription-date">Abonniert am {{ sub.created_at|date:"d.m.Y" }}</div>
{% if sub.overview %}
<div class="subscription-overview">{{ sub.overview|truncatechars:100 }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine Filme abonniert.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="auth-container">
<h2>Registrieren</h2>
<form method="post" class="auth-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary">Registrieren</button>
</form>
<div class="auth-links">
<p>Bereits ein Konto? <a href="{% url 'accounts:login' %}">Jetzt anmelden</a></p>
</div>
</div>
{% endblock %}

3
accounts/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

19
accounts/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
app_name = 'accounts'
urlpatterns = [
path('login/', views.jellyfin_login, name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('register/', views.RegisterView.as_view(), name='register'),
path('profile/', views.profile, name='profile'),
path('password_change/', auth_views.PasswordChangeView.as_view(
template_name='accounts/password_change.html',
success_url='done/'
), name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(
template_name='accounts/password_change_done.html'
), name='password_change_done'),
]

117
accounts/utils.py Normal file
View File

@@ -0,0 +1,117 @@
import requests
from django.conf import settings
from django.core.cache import cache
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages
class JellyfinClient:
def __init__(self):
# Basis-Einstellungen aus den Django-Settings
self.client = settings.JELLYFIN_CLIENT
self.version = settings.JELLYFIN_VERSION
self.device = settings.JELLYFIN_DEVICE
self.device_id = settings.JELLYFIN_DEVICE_ID
self.server_url = None # Wird später gesetzt
self.api_key = None # Optional, wird aus den AppSettings geholt wenn nötig
def authenticate(self, username, password):
"""Authenticate with Jellyfin and return user info if successful"""
if not self.server_url:
raise ValueError("Keine Server-URL angegeben")
# Stelle sicher, dass die URL ein Protokoll hat
if not self.server_url.startswith(('http://', 'https://')):
self.server_url = f'http://{self.server_url}'
# Entferne trailing slashes
self.server_url = self.server_url.rstrip('/')
headers = {
'X-Emby-Authorization': (
f'MediaBrowser Client="{self.client}", '
f'Device="{self.device}", '
f'DeviceId="{self.device_id}", '
f'Version="{self.version}"'
)
}
auth_data = {
'Username': username,
'Pw': password
}
try:
response = requests.post(
f'{self.server_url}/Users/AuthenticateByName',
json=auth_data,
headers=headers,
timeout=10
)
response.raise_for_status()
data = response.json()
return {
'user_id': data['User']['Id'],
'access_token': data['AccessToken'],
'is_admin': data['User'].get('Policy', {}).get('IsAdministrator', False)
}
except requests.exceptions.ConnectionError:
raise ValueError("Verbindung zum Server nicht möglich. Bitte überprüfen Sie die Server-URL.")
except requests.exceptions.Timeout:
raise ValueError("Zeitüberschreitung bei der Verbindung zum Server.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return None # Authentifizierung fehlgeschlagen
raise ValueError(f"HTTP-Fehler: {e.response.status_code}")
except Exception as e:
return None
def is_admin(self, user_id, token):
"""Check if user is admin in Jellyfin"""
cache_key = f'jellyfin_admin_{user_id}'
# Check cache first
cached = cache.get(cache_key)
if cached is not None:
return cached
headers = {
'X-Emby-Authorization': (
f'MediaBrowser Client="{self.client}", '
f'Device="{self.device}", '
f'DeviceId="{self.device_id}", '
f'Version="{self.version}", '
f'Token="{token}"'
)
}
try:
response = requests.get(
f'{self.server_url}/Users/{user_id}',
headers=headers
)
response.raise_for_status()
data = response.json()
is_admin = data.get('Policy', {}).get('IsAdministrator', False)
# Cache result for 5 minutes
cache.set(cache_key, is_admin, 300)
return is_admin
except:
return False
def jellyfin_admin_required(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
messages.error(request, 'Sie müssen angemeldet sein, um diese Seite zu sehen.')
return redirect('accounts:login')
if not request.user.is_jellyfin_admin:
messages.error(request, 'Sie benötigen Admin-Rechte, um diese Seite zu sehen.')
return redirect('index')
return view_func(request, *args, **kwargs)
return _wrapped_view

132
accounts/views.py Normal file
View File

@@ -0,0 +1,132 @@
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import login
from django.conf import settings
from .forms import CustomUserCreationForm, CustomUserChangeForm, JellyfinLoginForm
from .models import User
from .utils import JellyfinClient
class RegisterView(CreateView):
form_class = CustomUserCreationForm
template_name = 'accounts/register.html'
success_url = reverse_lazy('accounts:login')
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.')
return response
@login_required
def profile(request):
if request.method == 'POST':
form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'E-Mail gespeichert.')
return redirect('accounts:profile')
else:
form = CustomUserChangeForm(instance=request.user)
# Lade Abonnements
series_subs = request.user.series_subscriptions.all()
movie_subs = request.user.movie_subscriptions.all()
# Best-effort Backfill fehlender Poster, damit die Profilseite Bilder zeigt
try:
from settingspanel.models import AppSettings
from arr_api.services import sonarr_get_series, radarr_lookup_movie_by_title
cfg = AppSettings.current()
# Serien
for sub in series_subs:
if not sub.series_poster and sub.series_id:
details = sonarr_get_series(sub.series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
if details and details.get('series_poster'):
sub.series_poster = details['series_poster']
if not sub.series_overview:
sub.series_overview = details.get('series_overview') or ''
if not sub.series_genres:
sub.series_genres = details.get('series_genres') or []
sub.save(update_fields=['series_poster', 'series_overview', 'series_genres'])
# Filme
for sub in movie_subs:
if not sub.poster:
details = radarr_lookup_movie_by_title(sub.title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
if details and details.get('poster'):
sub.poster = details['poster']
if not sub.overview:
sub.overview = details.get('overview') or ''
if not sub.genres:
sub.genres = details.get('genres') or []
sub.save(update_fields=['poster', 'overview', 'genres'])
except Exception:
# still show page even if lookups fail
pass
return render(request, 'accounts/profile.html', {
'form': form,
'series_subs': series_subs,
'movie_subs': movie_subs,
})
def jellyfin_login(request):
if request.method == 'POST':
form = JellyfinLoginForm(request.POST)
if form.is_valid():
username = form.cleaned_data['username']
password = form.cleaned_data['password']
# Jellyfin-URL aus AppSettings
from settingspanel.models import AppSettings
app_settings = AppSettings.current()
server_url = app_settings.get_jellyfin_url()
if not server_url:
messages.error(request, 'Jellyfin Server ist nicht konfiguriert. Bitte Setup abschließen.')
return render(request, 'accounts/login.html', {'form': form})
try:
client = JellyfinClient()
client.server_url = server_url
auth_result = client.authenticate(username, password)
if not auth_result:
messages.error(request, 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.')
return render(request, 'accounts/login.html', {'form': form})
# Existierenden User finden oder neu erstellen
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
user = User.objects.create_user(
username=username,
email=f"{username}@jellyfin.local"
)
# Jellyfin Daten aktualisieren
user.jellyfin_user_id = auth_result['user_id']
user.jellyfin_token = auth_result['access_token']
user.jellyfin_server = server_url
user.save()
if auth_result['is_admin']:
user.is_admin = True
user.save()
login(request, user)
messages.success(request, f'Willkommen, {username}!')
return redirect('arr_api:index')
except ValueError as e:
messages.error(request, str(e))
except Exception as e:
messages.error(request, f'Verbindungsfehler: {str(e)}')
# invalid form or error path
return render(request, 'accounts/login.html', {'form': form})
else:
form = JellyfinLoginForm()
return render(request, 'accounts/login.html', {'form': form})

View File

@@ -0,0 +1,14 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from arr_api.notifications import check_and_notify_users
class Command(BaseCommand):
help = 'Prüft neue Medien und sendet Benachrichtigungen'
def handle(self, *args, **kwargs):
self.stdout.write(f'[{timezone.now()}] Starte Medien-Check...')
try:
check_and_notify_users()
self.stdout.write(self.style.SUCCESS(f'[{timezone.now()}] Medien-Check erfolgreich beendet'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'[{timezone.now()}] Fehler beim Medien-Check: {str(e)}'))

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.5 on 2025-08-10 11:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MovieSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('movie_id', models.IntegerField()),
('title', models.CharField(max_length=255)),
('poster', models.URLField(blank=True, null=True)),
('overview', models.TextField(blank=True)),
('genres', models.JSONField(default=list)),
('release_date', models.DateTimeField(null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_subscriptions', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'movie_id')},
},
),
migrations.CreateModel(
name='SeriesSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('series_id', models.IntegerField()),
('series_title', models.CharField(max_length=255)),
('series_poster', models.URLField(blank=True, null=True)),
('series_overview', models.TextField(blank=True)),
('series_genres', models.JSONField(default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_subscriptions', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'series_id')},
},
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.2.5 on 2025-08-10 14:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('arr_api', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SentNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('media_id', models.IntegerField()),
('media_type', models.CharField(max_length=10)),
('media_title', models.CharField(max_length=255)),
('air_date', models.DateField()),
('sent_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-sent_at'],
'unique_together': {('user', 'media_id', 'media_type', 'air_date')},
},
),
]

View File

@@ -1,3 +1,53 @@
from django.db import models
from django.conf import settings
# Create your models here.
class SeriesSubscription(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='series_subscriptions')
series_id = models.IntegerField()
series_title = models.CharField(max_length=255)
series_poster = models.URLField(null=True, blank=True)
series_overview = models.TextField(blank=True)
series_genres = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'series_id'] # Ein User kann eine Serie nur einmal abonnieren
def __str__(self):
return self.series_title
class MovieSubscription(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='movie_subscriptions')
movie_id = models.IntegerField()
title = models.CharField(max_length=255)
poster = models.URLField(null=True, blank=True)
overview = models.TextField(blank=True)
genres = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'movie_id'] # Ein User kann einen Film nur einmal abonnieren
def __str__(self):
return self.title
class SentNotification(models.Model):
"""
Speichert gesendete Benachrichtigungen um Duplikate zu vermeiden
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
media_id = models.IntegerField()
media_type = models.CharField(max_length=10) # 'series' oder 'movie'
media_title = models.CharField(max_length=255)
air_date = models.DateField()
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['user', 'media_id', 'media_type', 'air_date']
ordering = ['-sent_at']
def __str__(self):
return f"{self.media_type}: {self.media_title} for {self.user.username} on {self.air_date}"

356
arr_api/notifications.py Normal file
View File

@@ -0,0 +1,356 @@
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
from settingspanel.models import AppSettings
# from accounts.utils import JellyfinClient # not needed for availability; use Sonarr/Radarr instead
import requests
from dateutil.parser import isoparse
import logging
logger = logging.getLogger(__name__)
def _set_runtime_email_settings():
app_settings = AppSettings.current()
sec = (app_settings.mail_secure or '').strip().lower()
use_tls = sec in ('tls', 'starttls', 'start_tls', 'tls1.2', 'tls1_2')
use_ssl = sec in ('ssl', 'smtps')
# Prefer SSL over TLS if both matched somehow
if use_ssl:
use_tls = False
# Apply email settings dynamically for this process
settings.EMAIL_HOST = (app_settings.mail_host or settings.EMAIL_HOST)
# Port defaults if not provided
if app_settings.mail_port:
settings.EMAIL_PORT = int(app_settings.mail_port)
else:
if use_ssl and not settings.EMAIL_PORT:
settings.EMAIL_PORT = 465
elif use_tls and not settings.EMAIL_PORT:
settings.EMAIL_PORT = 587
settings.EMAIL_USE_TLS = use_tls
settings.EMAIL_USE_SSL = use_ssl
settings.EMAIL_HOST_USER = app_settings.mail_user or settings.EMAIL_HOST_USER
settings.EMAIL_HOST_PASSWORD = app_settings.mail_password or settings.EMAIL_HOST_PASSWORD
# From email fallback
if app_settings.mail_from:
settings.DEFAULT_FROM_EMAIL = app_settings.mail_from
elif not getattr(settings, 'DEFAULT_FROM_EMAIL', None):
host = (settings.EMAIL_HOST or 'localhost')
settings.DEFAULT_FROM_EMAIL = f'noreply@{host}'
# return summary for debugging
return {
'host': settings.EMAIL_HOST,
'port': settings.EMAIL_PORT,
'use_tls': settings.EMAIL_USE_TLS,
'use_ssl': settings.EMAIL_USE_SSL,
'from_email': settings.DEFAULT_FROM_EMAIL,
'auth_user_set': bool(settings.EMAIL_HOST_USER),
}
def send_notification_email(
user,
media_title,
media_type,
overview=None,
poster_url=None,
episode_title=None,
season=None,
episode=None,
air_date=None,
year=None,
release_type=None,
):
"""
Sendet eine Benachrichtigungs-E-Mail an einen User mit erweiterten Details
"""
eff = _set_runtime_email_settings()
logger.info(
"Email settings: host=%s port=%s tls=%s ssl=%s from=%s auth_user_set=%s",
eff['host'], eff['port'], eff['use_tls'], eff['use_ssl'], eff['from_email'], eff['auth_user_set']
)
# Format air date if provided
air_date_str = None
if air_date:
try:
from dateutil.parser import isoparse as _iso
dt = _iso(air_date) if isinstance(air_date, str) else air_date
try:
tz = timezone.get_current_timezone()
dt = dt.astimezone(tz)
except Exception:
pass
air_date_str = dt.strftime('%d.%m.%Y %H:%M')
except Exception:
air_date_str = str(air_date)
context = {
'username': user.username,
'title': media_title,
'type': 'Serie' if media_type == 'series' else 'Film',
'overview': overview,
'poster_url': poster_url,
'episode_title': episode_title,
'season': season,
'episode': episode,
'air_date': air_date_str,
'year': year,
'release_type': release_type,
}
subject = f"Neue {context['type']} verfügbar: {media_title}"
message = render_to_string('arr_api/email/new_media_notification.html', context)
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=message,
fail_silently=False,
)
def _get_arr_cfg():
cfg = AppSettings.current()
return {
'sonarr_url': (cfg.sonarr_url or '').strip(),
'sonarr_key': (cfg.sonarr_api_key or '').strip(),
'radarr_url': (cfg.radarr_url or '').strip(),
'radarr_key': (cfg.radarr_api_key or '').strip(),
}
def _sonarr_get(url_base, api_key, path, params=None, timeout=10):
if not url_base or not api_key:
return None
url = f"{url_base.rstrip('/')}{path}"
try:
r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
except requests.RequestException:
return None
def _radarr_get(url_base, api_key, path, params=None, timeout=10):
if not url_base or not api_key:
return None
url = f"{url_base.rstrip('/')}{path}"
try:
r = requests.get(url, headers={"X-Api-Key": api_key}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
except requests.RequestException:
return None
def sonarr_episode_has_file(series_id: int, season: int, episode: int) -> bool:
cfg = _get_arr_cfg()
data = _sonarr_get(cfg['sonarr_url'], cfg['sonarr_key'], "/api/v3/episode", params={"seriesId": series_id}) or []
for ep in data:
if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode:
return bool(ep.get("hasFile"))
return False
def radarr_movie_has_file(movie_id: int) -> bool:
cfg = _get_arr_cfg()
data = _radarr_get(cfg['radarr_url'], cfg['radarr_key'], f"/api/v3/movie/{movie_id}")
if not data:
return False
return bool(data.get("hasFile"))
def get_todays_sonarr_calendar():
from .services import sonarr_calendar
cfg = _get_arr_cfg()
items = sonarr_calendar(days=1, base_url=cfg['sonarr_url'], api_key=cfg['sonarr_key']) or []
today = timezone.now().date()
todays = []
for it in items:
try:
ad = isoparse(it.get("airDateUtc")) if it.get("airDateUtc") else None
if ad and ad.date() == today:
todays.append(it)
except Exception:
pass
return todays
def get_todays_radarr_calendar():
from .services import radarr_calendar
cfg = _get_arr_cfg()
items = radarr_calendar(days=1, base_url=cfg['radarr_url'], api_key=cfg['radarr_key']) or []
today = timezone.now().date()
todays = []
for it in items:
# consider any of the dates equal today
for k in ("inCinemas", "physicalRelease", "digitalRelease"):
v = it.get(k)
if not v:
continue
try:
d = isoparse(v).date()
if d == today:
todays.append(it)
break
except Exception:
continue
return todays
def check_jellyfin_availability(user, media_id, media_type):
"""
Ersetzt: Wir prüfen Verfügbarkeit über Sonarr/Radarr (hasFile),
was zuverlässig ist, wenn Jellyfin dieselben Ordner scannt.
"""
# user is unused here; kept for backward compatibility
if media_type == 'series':
# cannot decide without season/episode here; will be handled in main loop
return False
else:
return radarr_movie_has_file(media_id)
def check_and_notify_users():
"""
Hauptfunktion die periodisch aufgerufen wird.
Prüft neue Medien und sendet Benachrichtigungen.
"""
from .models import SeriesSubscription, MovieSubscription, SentNotification
# calendars for today
todays_series = get_todays_sonarr_calendar()
todays_movies = get_todays_radarr_calendar()
# index by ids for quick lookup
series_idx = {}
for it in todays_series:
sid = it.get("seriesId")
if not sid:
continue
series_idx.setdefault(sid, []).append(it)
movie_idx = {it.get("movieId"): it for it in todays_movies if it.get("movieId")}
today = timezone.now().date()
# Serien-Abos
for sub in SeriesSubscription.objects.select_related('user').all():
if sub.series_id not in series_idx:
continue
# iterate today's episodes for this series
for ep in series_idx[sub.series_id]:
season = ep.get("seasonNumber")
number = ep.get("episodeNumber")
if season is None or number is None:
continue
# duplicate guard (per series per day per user)
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
already_notified = SentNotification.objects.filter(
media_id=sub.series_id,
media_type='series',
air_date=today,
user=sub.user
).exists()
if already_notified:
continue
# check availability via Sonarr hasFile
if sonarr_episode_has_file(sub.series_id, season, number):
if not sub.user.email:
continue
send_notification_email(
user=sub.user,
media_title=sub.series_title,
media_type='series',
overview=sub.series_overview,
poster_url=ep.get('seriesPoster'),
episode_title=ep.get('title'),
season=season,
episode=number,
air_date=ep.get('airDateUtc'),
)
# mark as sent unless duplicates are allowed
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create(
user=sub.user,
media_id=sub.series_id,
media_type='series',
media_title=sub.series_title,
air_date=today
)
# Film-Abos
for sub in MovieSubscription.objects.select_related('user').all():
it = movie_idx.get(sub.movie_id)
if not it:
continue
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
already_notified = SentNotification.objects.filter(
media_id=sub.movie_id,
media_type='movie',
air_date=today,
user=sub.user
).exists()
if already_notified:
continue
if radarr_movie_has_file(sub.movie_id):
if not sub.user.email:
continue
# detect which release matched today
rel = None
try:
for key, name in (("digitalRelease", "Digital"), ("physicalRelease", "Disc"), ("inCinemas", "Kino")):
v = it.get(key)
if not v:
continue
d = isoparse(v).date()
if d == today:
rel = name
break
except Exception:
pass
send_notification_email(
user=sub.user,
media_title=sub.title,
media_type='movie',
overview=sub.overview,
poster_url=it.get('posterUrl'),
year=it.get('year'),
release_type=rel,
)
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
SentNotification.objects.create(
user=sub.user,
media_id=sub.movie_id,
media_type='movie',
media_title=sub.title,
air_date=today
)
def has_new_episode_today(series_id):
"""
Legacy helper no longer used directly.
"""
return True
def has_movie_release_today(movie_id):
"""
Legacy helper no longer used directly.
"""
return True

View File

@@ -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"),
}

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 700px;
margin: 0 auto;
padding: 20px;
}
.header {
border-bottom: 2px solid #3b82f6;
padding-bottom: 10px;
margin-bottom: 20px;
}
.content {
background: #f8fafc;
padding: 20px;
border-radius: 8px;
display: grid;
grid-template-columns: 140px 1fr;
gap: 16px;
align-items: start;
}
.title {
color: #1e40af;
font-size: 22px;
margin: 0 0 8px 0;
}
.overview {
color: #444;
margin: 10px 0;
}
.meta {
color: #555;
font-size: 14px;
margin: 6px 0;
}
.poster {
width: 140px;
border-radius: 6px;
background: #e5e7eb;
object-fit: cover;
}
.kbd {
display: inline-block;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-bottom-width: 2px;
border-radius: 4px;
font-size: 12px;
background: #fff;
}
</style>
</head>
<body>
<div class="header">
<h1>Neue {{ type }} verfügbar!</h1>
</div>
<div class="content">
{% if poster_url %}
<img class="poster" src="{{ poster_url }}" alt="Poster" />
{% else %}
<div></div>
{% endif %}
<div>
<p>Hallo {{ username }},</p>
<h2 class="title">{{ title }}</h2>
{% if episode_title %}
<p class="meta">Episode: <strong>{{ episode_title }}</strong></p>
{% endif %}
{% if season and episode %}
<p class="meta"><span class="kbd">S{{ season }}E{{ episode }}</span></p>
{% endif %}
{% if year %}
<p class="meta">Jahr: <strong>{{ year }}</strong></p>
{% endif %}
{% if release_type %}
<p class="meta">Release: {{ release_type }}</p>
{% endif %}
{% if air_date %}
<p class="meta">Veröffentlicht am: {{ air_date }}</p>
{% endif %}
{% if overview %}
<p class="overview">{{ overview }}</p>
{% endif %}
<p>Du kannst das jetzt auf Jellyfin anschauen.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,451 +1,27 @@
<!DOCTYPE html>
<html lang="de">
{% extends "base.html" %}
{% load static %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subscribarr Übersicht</title>
<style>
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
{% block title %}Subscribarr Übersicht{% endblock %}
* {
box-sizing: border-box;
}
{% block extra_style %}
<link rel="stylesheet" href="{% static 'css/index.css' %}">
{% endblock %}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
h1 {
margin: 4px 0 12px;
font-size: clamp(1.2rem, 2.5vw, 1.6rem);
}
/* Controls */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.controls form {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 220px;
}
.controls input[type=text] {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
min-width: 0;
}
.controls button[type=submit] {
padding: 10px 14px;
border-radius: 10px;
border: 0;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.seg {
display: inline-flex;
background: #0f0f17;
border: 1px solid #28293a;
border-radius: 10px;
overflow: hidden;
}
.seg a {
padding: 8px 12px;
color: var(--text);
text-decoration: none;
}
.seg a.active {
background: var(--accent);
color: #fff;
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
@media (min-width: 900px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
overflow: hidden;
display: flex;
gap: 12px;
padding: 12px;
transition: transform .08s ease, border-color .08s;
cursor: pointer;
}
.card:active,
.card:hover {
transform: translateY(-2px);
border-color: #2a2b44;
}
.poster {
width: 110px;
height: 165px;
background: #222233;
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto;
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.meta {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episodes {
max-height: 210px;
overflow: auto;
padding-right: 6px;
}
.ep {
font-size: 0.92rem;
padding: 6px 0;
border-bottom: 1px dashed #25263a;
}
.muted {
color: var(--muted);
font-size: 0.9rem;
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px;
}
.movie-card img {
width: 100%;
border-radius: 8px;
margin-bottom: 8px;
display: block;
height: auto;
}
.section {
margin-top: 22px;
}
.section h2 {
font-size: 1.1rem;
margin: 12px 0;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10, 12, 20, .55);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 12px;
}
.modal {
width: min(960px, 100%);
max-height: 92vh;
overflow: auto;
background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%);
border: 1px solid #2a2b44;
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .6);
}
.modal-header {
position: sticky;
grid-template-columns: auto 1fr auto;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: 130px 1fr auto;
gap: 14px;
align-items: center;
padding: 16px;
background: rgba(13, 15, 22, .85);
backdrop-filter: blur(4px);
border-bottom: 1px solid #20223a;
}
.m-poster {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 130px;
height: 195px;
border-radius: 10px;
overflow: hidden;
background: #222233;
}
.m-poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.m-title {
font-size: 1.25rem;
font-weight: 750;
line-height: 1.2;
margin-bottom: 6px;
}
.m-sub {
color: var(--muted);
font-size: .92rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.badge {
padding: 4px 8px;
border-radius: 999px;
background: #171a26;
border: 1px solid #2a2b44;
font-size: .82rem;
color: #cfd3ea;
}
.modal-close {
margin-left: auto;
align-self: start;
background: #1a1f33;
border: 1px solid #2a2b44;
color: #c9cbe3;
width: 36px;
height: 36px;
justify-self: end;
border-radius: 10px;
cursor: pointer;
display: grid;
place-items: center;
font-size: 1.4rem;
line-height: 1;
transition: transform .08s ease, background .12s ease, border-color .12s ease;
}
.modal-close:hover {
background: #243055;
border-color: #3b4aa0;
transform: translateY(-1px);
}
.modal-body {
padding: 16px;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 900px) {
.modal-body {
grid-template-columns: 1.2fr .8fr;
}
}
.section-block {
background: #101327;
border: 1px solid #20223a;
border-radius: 12px;
padding: 14px;
}
.section-title {
font-size: 1rem;
font-weight: 650;
margin: 0 0 8px;
}
.section-divider {
height: 1px;
background: #20223a;
margin: 10px 0;
opacity: .9;
}
.ep-row {
border-bottom: 1px dashed #262947;
padding: 8px 0;
font-size: .94rem;
}
.ep-row:last-child {
border-bottom: 0;
}
/* control */
.controls input[type=number] {
width: 90px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
-moz-appearance: textfield;
}
.btn-subscribe {
padding: 8px 14px;
border-radius: 10px;
background: #1f6f3a;
border: 1px solid #2a2b34;
color: #fff;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .08s ease;
}
.btn-subscribe:hover {
background: #2b8f4d;
}
.btn-subscribe:active {
transform: translateY(1px);
}
.subscribed {
outline: 3px solid #1f6f3a;
/* grüne Markierung am Element */
outline-offset: 2px;
}
.controls input[type=number]::-webkit-outer-spin-button,
.controls input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.controls input[type=number]:focus {
border-color: var(--accent);
outline: none;
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px;
cursor: pointer;
/* klickbar */
transition: transform .08s ease, border-color .08s;
/* wie .card */
}
.movie-card:hover,
.movie-card:focus-within {
transform: translateY(-2px);
border-color: #2a2b44;
}
.movie-card:active {
transform: translateY(0);
/* kleiner Tap-Feedback */
}
</style>
</head>
<body>
<div class="topbar" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div></div>
<div style="display:flex;gap:8px;align-items:center">
<div class="debug" title="Debug"
style="padding:8px 10px;border:1px solid #2a2a34;border-radius:10px;background:#111119;color:#cfd3ea;font-size:.9rem;">
{% block content %}
<div class="debug-info">
kind={{ kind }} · days={{ days }} · series={{ series_grouped|length }} · movies={{ movies|length }}
</div>
<a href="/settings/" class="btn"
style="padding:8px 12px;border-radius:10px;border:1px solid #2a2a34;background:#111119;color:#fff;text-decoration:none">
⚙️ Einstellungen
</a>
</div>
</div>
</div>
<div class="wrap">
{% csrf_token %}
<div class="wrap">
<h1>Subscribarr</h1>
<div class="controls">
<form method="get" class="controls-form">
<input type="hidden" name="kind" value="{{ kind|default:'all' }}">
<input type="text" name="q" placeholder="Suche Serien/Filme…" value="{{ query|default:'' }}">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}"
title="Zeitraum in Tagen">
<input type="number" name="days" min="1" max="365" value="{{ days|default:30 }}" title="Zeitraum in Tagen">
<button type="submit">Suchen</button>
</form>
@@ -467,8 +43,7 @@
<div class="grid">
{% for s in series_grouped %}
<div class="card" data-series-id="{{ s.seriesId }}" data-title="{{ s.seriesTitle|escape }}"
data-poster="{{ s.seriesPoster|default:'' }}"
data-overview="{{ s.seriesOverview|default:''|escape }}">
data-poster="{{ s.seriesPoster|default:'' }}" data-overview="{{ s.seriesOverview|default:''|escape }}">
<div class="poster">
{% if s.seriesPoster %}
<img src="{{ s.seriesPoster }}" alt="{{ s.seriesTitle }}">
@@ -528,10 +103,10 @@
</div>
</div>
{% endif %}
</div>
</div>
<!-- Modal -->
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true">
<!-- Modal -->
<div id="modalBackdrop" class="modal-backdrop" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal">
<div class="modal-header">
<div class="m-poster-wrap">
@@ -559,9 +134,9 @@
</div>
</div>
</div>
</div>
</div>
<script>
<script>
(function () {
// ===== Helpers =====
const $ = (sel, root = document) => root.querySelector(sel);
@@ -596,21 +171,85 @@
backdrop.addEventListener("click", e => { if (e.target === backdrop) closeModal(); });
window.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); });
// ===== Subscribe-Only-UI (mit localStorage) =====
// ===== Subscribe-UI mit Backend-Sync =====
function subKey(card) {
if (!card) return null;
if (card.classList.contains("card") && card.dataset.seriesId) return "series:" + card.dataset.seriesId;
return "movie:" + (card.dataset.title || "");
}
// Cache für Abonnement-Status
const subCache = new Map();
async function loadAllSubs() {
try {
const [seriesResp, moviesResp] = await Promise.all([
fetch('/api/series/subscriptions/'),
fetch('/api/movies/subscriptions/')
]);
if (seriesResp.ok) {
const seriesSubs = await seriesResp.json();
seriesSubs.forEach(id => subCache.set(`series:${id}`, true));
}
if (moviesResp.ok) {
const movieSubs = await moviesResp.json();
movieSubs.forEach(title => subCache.set(`movie:${title}`, true));
}
} catch (err) {
console.error("Failed to load subscriptions:", err);
}
}
function loadSub(card) {
const k = subKey(card);
return k ? localStorage.getItem("sub:" + k) === "1" : false;
return k ? subCache.get(k) || false : false;
}
function saveSub(card, on) {
async function saveSub(card, on) {
const k = subKey(card);
if (!k) return;
if (on) localStorage.setItem("sub:" + k, "1");
else localStorage.removeItem("sub:" + k);
const [type, id] = k.split(":");
try {
const resp = await fetch(`/api/${type}/${on ? 'subscribe' : 'unsubscribe'}/${encodeURIComponent(id)}/`, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
if (resp.status === 403) {
// Nicht eingeloggt
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// Cache aktualisieren
if (on) {
subCache.set(k, true);
} else {
subCache.delete(k);
}
} catch (err) {
console.error("Failed to update subscription:", err);
// Cache-Update rückgängig machen bei Fehler
if (on) {
subCache.delete(k);
} else {
subCache.set(k, true);
}
// Fehlermeldung anzeigen
const errorMsg = document.createElement('div');
errorMsg.className = 'error-message';
errorMsg.textContent = 'Fehler beim Aktualisieren des Abonnements. Bitte versuchen Sie es später erneut.';
document.body.appendChild(errorMsg);
setTimeout(() => errorMsg.remove(), 3000);
}
}
function applySubUI(card, on) {
if (card) card.classList.toggle("subscribed", !!on); // grüne Outline via .subscribed (CSS)
@@ -620,8 +259,14 @@
}
}
// Beim Laden: gespeicherten Zustand auf alle Karten anwenden
$$(".card, .movie-card").forEach(c => applySubUI(c, loadSub(c)));
// Beim Laden: Alle Abonnements in einem API-Call laden
(async () => {
await loadAllSubs();
const cards = $$(".card, .movie-card");
cards.forEach(card => {
applySubUI(card, loadSub(card));
});
})();
// ===== Serien-Karten öffnen =====
$$(".card").forEach(card => {
@@ -681,6 +326,11 @@
// Subscribe-UI für diese Karte setzen
applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit
loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard));
});
openModal();
});
});
@@ -709,17 +359,31 @@
// Subscribe-UI für diese Karte setzen
applySubUI(lastClickedCard, loadSub(lastClickedCard));
// Status nochmal aktualisieren zur Sicherheit
loadAllSubs().then(() => {
applySubUI(lastClickedCard, loadSub(lastClickedCard));
});
openModal();
});
});
// ===== Subscribe-Button im Modal toggelt nur UI + localStorage =====
// ===== Subscribe-Button im Modal mit Backend-Sync =====
if (subscribeBtn) {
subscribeBtn.addEventListener("click", () => {
subscribeBtn.addEventListener("click", async () => {
if (!lastClickedCard) return;
const now = !loadSub(lastClickedCard);
saveSub(lastClickedCard, now);
applySubUI(lastClickedCard, now);
const current = await loadSub(lastClickedCard);
const newState = !current;
// Optimistic UI update
applySubUI(lastClickedCard, newState);
// Backend-Sync
await saveSub(lastClickedCard, newState);
// Status neu laden zur Sicherheit
const finalState = await loadSub(lastClickedCard);
applySubUI(lastClickedCard, finalState);
});
}
@@ -731,8 +395,8 @@
el.textContent = isNaN(d) ? v : d.toLocaleString();
});
})();
</script>
</script>
{% endblock %}
</body>
</html>

View File

@@ -1,7 +1,25 @@
from django.urls import path
from .views import SonarrAiringView, RadarrUpcomingMoviesView
from .views import (
ArrIndexView, SeriesSubscribeView, SeriesUnsubscribeView,
MovieSubscribeView, MovieUnsubscribeView,
ListSeriesSubscriptionsView, ListMovieSubscriptionsView
)
app_name = 'arr_api'
urlpatterns = [
path("sonarr/airing", SonarrAiringView.as_view(), name="sonarr-airing"),
path("radarr/upcoming", RadarrUpcomingMoviesView.as_view(), name="radarr-upcoming"),
path('', ArrIndexView.as_view(), name='index'),
# Series URLs
path('api/series/subscribe/<int:series_id>/', SeriesSubscribeView.as_view(), name='subscribe-series'),
path('api/series/unsubscribe/<int:series_id>/', SeriesUnsubscribeView.as_view(), name='unsubscribe-series'),
path('api/series/subscriptions/', ListSeriesSubscriptionsView.as_view(), name='list-series-subscriptions'),
# Movie URLs
path('api/movies/subscribe/<str:title>/', MovieSubscribeView.as_view(), name='subscribe-movie'),
path('api/movies/unsubscribe/<str:title>/', MovieUnsubscribeView.as_view(), name='unsubscribe-movie'),
path('api/movies/subscriptions/', ListMovieSubscriptionsView.as_view(), name='list-movie-subscriptions'),
# Get all subscriptions
]

View File

@@ -1,13 +1,18 @@
from collections import defaultdict
from django.shortcuts import render
from django.shortcuts import render, redirect, get_object_or_404
from django.views import View
from django.contrib import messages
from django.views.decorators.http import require_POST
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from settingspanel.models import AppSettings
from .services import sonarr_calendar, radarr_calendar, ArrServiceError
from .models import SeriesSubscription, MovieSubscription
def _get_int(request, key, default):
@@ -27,26 +32,26 @@ def _arr_conf_from_db():
}
class SonarrAiringView(APIView):
def get(self, request):
days = _get_int(request, "days", 30)
conf = _arr_conf_from_db()
try:
data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
return Response({"count": len(data), "results": data})
except ArrServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
#class SonarrAiringView(APIView):
# def get(self, request):
# days = _get_int(request, "days", 30)
# conf = _arr_conf_from_db()
# try:
# data = sonarr_calendar(days=days, base_url=conf["sonarr_url"], api_key=conf["sonarr_key"])
# return Response({"count": len(data), "results": data})
# except ArrServiceError as e:
# return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
class RadarrUpcomingMoviesView(APIView):
def get(self, request):
days = _get_int(request, "days", 60)
conf = _arr_conf_from_db()
try:
data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
return Response({"count": len(data), "results": data})
except ArrServiceError as e:
return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
#class RadarrUpcomingMoviesView(APIView):
# def get(self, request):
# days = _get_int(request, "days", 60)
# conf = _arr_conf_from_db()
# try:
# data = radarr_calendar(days=days, base_url=conf["radarr_url"], api_key=conf["radarr_key"])
# return Response({"count": len(data), "results": data})
# except ArrServiceError as e:
# return Response({"error": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
class ArrIndexView(View):
@@ -75,10 +80,14 @@ class ArrIndexView(View):
eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()]
movies = [m for m in movies if q in (m.get("title") or "").lower()]
# Abonnierte Serien und Filme laden
subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True))
subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True))
# Gruppierung nach Serie
groups = defaultdict(lambda: {
"seriesId": None, "seriesTitle": None, "seriesPoster": None,
"seriesOverview": "", "seriesGenres": [], "episodes": [],
"seriesOverview": "", "seriesGenres": [], "episodes": [], "is_subscribed": False,
})
for e in eps:
sid = e["seriesId"]
@@ -101,8 +110,13 @@ class ArrIndexView(View):
series_grouped = []
for g in groups.values():
g["episodes"].sort(key=lambda x: (x["airDateUtc"] or ""))
g["is_subscribed"] = g["seriesId"] in subscribed_series_ids
series_grouped.append(g)
# Markiere abonnierte Filme
for movie in movies:
movie["is_subscribed"] = movie.get("movieId") in subscribed_movie_ids
return render(request, "arr_api/index.html", {
"query": q,
"kind": kind,
@@ -112,3 +126,252 @@ class ArrIndexView(View):
"series_grouped": series_grouped,
"movies": movies,
})
class SubscribeSeriesView(View):
@method_decorator(require_POST)
def post(self, request, series_id):
series_data = {
'series_id': series_id,
'series_title': request.POST.get('series_title'),
'series_poster': request.POST.get('series_poster'),
'series_overview': request.POST.get('series_overview'),
'series_genres': request.POST.getlist('series_genres[]', [])
}
subscription, created = SeriesSubscription.objects.get_or_create(
series_id=series_id,
defaults=series_data
)
if created:
messages.success(request, f'Serie "{series_data["series_title"]}" wurde abonniert!')
else:
messages.info(request, f'Serie "{series_data["series_title"]}" war bereits abonniert.')
return redirect('arr_api:index')
class UnsubscribeSeriesView(View):
@method_decorator(require_POST)
def post(self, request, series_id):
subscription = get_object_or_404(SeriesSubscription, series_id=series_id)
series_title = subscription.series_title
subscription.delete()
messages.success(request, f'Abonnement für "{series_title}" wurde beendet.')
return redirect('arr_api:index')
class SubscribeMovieView(View):
@method_decorator(require_POST)
def post(self, request, movie_id):
movie_data = {
'movie_id': movie_id,
'title': request.POST.get('title'),
'poster': request.POST.get('poster'),
'overview': request.POST.get('overview'),
'genres': request.POST.getlist('genres[]', []),
'release_date': request.POST.get('release_date')
}
subscription, created = MovieSubscription.objects.get_or_create(
movie_id=movie_id,
defaults=movie_data
)
if created:
messages.success(request, f'Film "{movie_data["title"]}" wurde abonniert!')
else:
messages.info(request, f'Film "{movie_data["title"]}" war bereits abonniert.')
return redirect('arr_api:index')
class UnsubscribeMovieView(View):
@method_decorator(require_POST)
def post(self, request, movie_id):
subscription = get_object_or_404(MovieSubscription, movie_id=movie_id)
movie_title = subscription.title
subscription.delete()
messages.success(request, f'Abonnement für "{movie_title}" wurde beendet.')
return redirect('arr_api:index')
@require_POST
@login_required
def subscribe_series(request, series_id):
"""Serie abonnieren"""
try:
# Existiert bereits?
if SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists():
return JsonResponse({'success': True, 'already_subscribed': True})
# Hole Serien-Details vom Sonarr
conf = _arr_conf_from_db()
# TODO: Sonarr API Call für Series Details
# Erstelle Subscription
sub = SeriesSubscription.objects.create(
user=request.user,
series_id=series_id,
series_title=request.POST.get('title', ''),
series_poster=request.POST.get('poster', ''),
series_overview=request.POST.get('overview', ''),
series_genres=request.POST.getlist('genres[]', [])
)
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@require_POST
@login_required
def unsubscribe_series(request, series_id):
"""Serie deabonnieren"""
try:
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@login_required
def is_subscribed_series(request, series_id):
"""Prüfe ob Serie abonniert ist"""
is_subbed = SeriesSubscription.objects.filter(user=request.user, series_id=series_id).exists()
return JsonResponse({'subscribed': is_subbed})
@require_POST
@login_required
def subscribe_movie(request, movie_id):
"""Film abonnieren"""
try:
# Existiert bereits?
if MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists():
return JsonResponse({'success': True, 'already_subscribed': True})
# Hole Film-Details vom Radarr
conf = _arr_conf_from_db()
# TODO: Radarr API Call für Movie Details
# Erstelle Subscription
sub = MovieSubscription.objects.create(
user=request.user,
movie_id=movie_id,
title=request.POST.get('title', ''),
poster=request.POST.get('poster', ''),
overview=request.POST.get('overview', ''),
genres=request.POST.getlist('genres[]', []),
release_date=request.POST.get('release_date')
)
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@require_POST
@login_required
def unsubscribe_movie(request, movie_id):
"""Film deabonnieren"""
try:
MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).delete()
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@login_required
def is_subscribed_movie(request, movie_id):
"""Prüfe ob Film abonniert ist"""
is_subbed = MovieSubscription.objects.filter(user=request.user, movie_id=movie_id).exists()
return JsonResponse({'subscribed': is_subbed})
@login_required
def get_subscriptions(request):
"""Hole alle Abonnements des Users"""
series = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
movies = MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)
return JsonResponse({
'series': list(series),
'movies': list(movies)
})
@method_decorator(login_required, name='dispatch')
class SeriesSubscribeView(APIView):
def post(self, request, series_id):
from .services import sonarr_get_series
cfg = AppSettings.current()
details = None
try:
details = sonarr_get_series(series_id, base_url=cfg.sonarr_url, api_key=cfg.sonarr_api_key)
except Exception:
details = None
defaults = {
'series_title': request.data.get('title', '') if request.data else '',
'series_poster': request.data.get('poster', '') if request.data else '',
'series_overview': request.data.get('overview', '') if request.data else '',
'series_genres': request.data.get('genres', []) if request.data else [],
}
if details:
defaults.update({
'series_title': details.get('series_title') or defaults['series_title'],
'series_poster': details.get('series_poster') or defaults['series_poster'],
'series_overview': details.get('series_overview') or defaults['series_overview'],
'series_genres': details.get('series_genres') or defaults['series_genres'],
})
sub, created = SeriesSubscription.objects.get_or_create(
user=request.user,
series_id=series_id,
defaults=defaults
)
return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@method_decorator(login_required, name='dispatch')
class SeriesUnsubscribeView(APIView):
def post(self, request, series_id):
SeriesSubscription.objects.filter(user=request.user, series_id=series_id).delete()
return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK)
@method_decorator(login_required, name='dispatch')
class MovieSubscribeView(APIView):
def post(self, request, title):
from .services import radarr_lookup_movie_by_title
cfg = AppSettings.current()
details = None
try:
details = radarr_lookup_movie_by_title(title, base_url=cfg.radarr_url, api_key=cfg.radarr_api_key)
except Exception:
details = None
defaults = {
'movie_id': (request.data.get('movie_id', 0) if request.data else 0) or 0,
'poster': request.data.get('poster', '') if request.data else '',
'overview': request.data.get('overview', '') if request.data else '',
'genres': request.data.get('genres', []) if request.data else [],
}
if details:
defaults.update({
'movie_id': details.get('movie_id') or defaults['movie_id'],
'poster': details.get('poster') or defaults['poster'],
'overview': details.get('overview') or defaults['overview'],
'genres': details.get('genres') or defaults['genres'],
})
sub, created = MovieSubscription.objects.get_or_create(
user=request.user,
title=title,
defaults=defaults
)
return Response({'status': 'subscribed'}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@method_decorator(login_required, name='dispatch')
class MovieUnsubscribeView(APIView):
def post(self, request, title):
MovieSubscription.objects.filter(user=request.user, title=title).delete()
return Response({'status': 'unsubscribed'}, status=status.HTTP_200_OK)
@method_decorator(login_required, name='dispatch')
class ListSeriesSubscriptionsView(APIView):
def get(self, request):
subs = SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)
return Response(list(subs))
@method_decorator(login_required, name='dispatch')
class ListMovieSubscriptionsView(APIView):
def get(self, request):
subs = MovieSubscription.objects.filter(user=request.user).values_list('title', flat=True)
return Response(list(subs))

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
subscribarr:
build: .
container_name: subscribarr
ports:
- "8000:8000"
environment:
# Django
- DJANGO_DEBUG=true
- DJANGO_ALLOWED_HOSTS=*
- DJANGO_SECRET_KEY=change-me
- DB_PATH=/app/data/db.sqlite3
- NOTIFICATIONS_ALLOW_DUPLICATES=false
# App Settings (optional, otherwise use first-run setup)
- JELLYFIN_URL=
- JELLYFIN_API_KEY=
- SONARR_URL=
- SONARR_API_KEY=
- RADARR_URL=
- RADARR_API_KEY=
- MAIL_HOST=
- MAIL_PORT=
- MAIL_SECURE=
- MAIL_USER=
- MAIL_PASSWORD=
- MAIL_FROM=
# Admin bootstrap (optional)
- ADMIN_USERNAME=
- ADMIN_PASSWORD=
- ADMIN_EMAIL=
# Cron schedule (default every 30min)
- CRON_SCHEDULE=*/30 * * * *
volumes:
- subscribarr-data:/app/data
restart: unless-stopped
volumes:
subscribarr-data:

88
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
# Wait for potential dependencies (none for sqlite)
# Apply migrations
python manage.py migrate --noinput
# Create admin user if provided
if [[ -n "${ADMIN_USERNAME:-}" && -n "${ADMIN_PASSWORD:-}" ]]; then
echo "Creating admin user ${ADMIN_USERNAME}"
python - <<'PY'
import os
from django.contrib.auth import get_user_model
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings')
django.setup()
User = get_user_model()
username = os.environ['ADMIN_USERNAME']
password = os.environ['ADMIN_PASSWORD']
email = os.environ.get('ADMIN_EMAIL') or f"{username}@local"
user, created = User.objects.get_or_create(username=username, defaults={'email': email})
if created:
user.set_password(password)
user.is_superuser = True
user.is_staff = True
user.is_admin = True
user.save()
else:
# update password if user exists
user.set_password(password)
user.is_superuser = True
user.is_staff = True
user.is_admin = True
user.save()
print("Admin ready")
PY
fi
# Seed AppSettings from environment if provided
python - <<'PY'
import os, django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'subscribarr.settings')
django.setup()
from settingspanel.models import AppSettings
s = AppSettings.current()
# Jellyfin
jf_url = os.environ.get('JELLYFIN_URL')
jf_key = os.environ.get('JELLYFIN_API_KEY')
if jf_url: s.jellyfin_server_url = jf_url
if jf_key: s.jellyfin_api_key = jf_key
# Sonarr / Radarr
sonarr_url = os.environ.get('SONARR_URL')
sonarr_key = os.environ.get('SONARR_API_KEY')
radarr_url = os.environ.get('RADARR_URL')
radarr_key = os.environ.get('RADARR_API_KEY')
if sonarr_url: s.sonarr_url = sonarr_url
if sonarr_key: s.sonarr_api_key = sonarr_key
if radarr_url: s.radarr_url = radarr_url
if radarr_key: s.radarr_api_key = radarr_key
# Mail
mail_host = os.environ.get('MAIL_HOST')
mail_port = os.environ.get('MAIL_PORT')
mail_secure = os.environ.get('MAIL_SECURE')
mail_user = os.environ.get('MAIL_USER')
mail_password = os.environ.get('MAIL_PASSWORD')
mail_from = os.environ.get('MAIL_FROM')
if mail_host: s.mail_host = mail_host
if mail_port: s.mail_port = int(mail_port)
if mail_secure: s.mail_secure = mail_secure
if mail_user: s.mail_user = mail_user
if mail_password: s.mail_password = mail_password
if mail_from: s.mail_from = mail_from
s.save()
print("AppSettings seeded from environment (if provided)")
PY
# Start cron for periodic job if schedule is set
if [[ -n "${CRON_SCHEDULE:-}" ]]; then
echo "Setting cron schedule: ${CRON_SCHEDULE}"
# write cronjob to user crontab
CRONLINE="${CRON_SCHEDULE} cd /app && /usr/local/bin/python manage.py check_new_media >> /app/cron.log 2>&1"
(crontab -l 2>/dev/null; echo "$CRONLINE") | crontab -
crond
fi
# Start server
exec python manage.py runserver 0.0.0.0:8000

View File

@@ -2,6 +2,58 @@ from django import forms
WIDE = {"class": "input-wide"}
class FirstRunSetupForm(forms.Form):
# Jellyfin (Required)
jellyfin_server_url = forms.URLField(
label="Jellyfin Server URL",
required=True,
help_text="Die URL deines Jellyfin-Servers"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=True,
widget=forms.PasswordInput(render_value=True),
help_text="Der API-Key aus den Jellyfin-Einstellungen"
)
# Sonarr (Optional)
sonarr_url = forms.URLField(
label="Sonarr URL",
required=False,
help_text="Die URL deines Sonarr-Servers"
)
sonarr_api_key = forms.CharField(
label="Sonarr API Key",
required=False,
widget=forms.PasswordInput(render_value=True)
)
# Radarr (Optional)
radarr_url = forms.URLField(
label="Radarr URL",
required=False,
help_text="Die URL deines Radarr-Servers"
)
radarr_api_key = forms.CharField(
label="Radarr API Key",
required=False,
widget=forms.PasswordInput(render_value=True)
)
class JellyfinSettingsForm(forms.Form):
jellyfin_server_url = forms.URLField(
label="Jellyfin Server URL",
required=False,
widget=forms.URLInput(attrs=WIDE),
help_text="z.B. http://localhost:8096"
)
jellyfin_api_key = forms.CharField(
label="Jellyfin API Key",
required=False,
widget=forms.PasswordInput(render_value=True, attrs=WIDE),
help_text="Admin API Key aus den Jellyfin Einstellungen"
)
class ArrSettingsForm(forms.Form):
sonarr_url = forms.URLField(label="Sonarr URL", required=False,
widget=forms.URLInput(attrs=WIDE))

View File

@@ -0,0 +1,23 @@
from django.shortcuts import redirect
from django.urls import reverse
from django.conf import settings
from .views import needs_setup
class SetupMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if needs_setup():
# URLs, die auch ohne Setup erlaubt sind
allowed_urls = [
reverse('settingspanel:setup'),
'/static/', # Für CSS/JS
]
# Prüfe, ob die aktuelle URL erlaubt ist
if not any(request.path.startswith(url) for url in allowed_urls):
return redirect('settingspanel:setup')
response = self.get_response(request)
return response

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.5 on 2025-08-10 13:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('settingspanel', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='appsettings',
name='jellyfin_api_key',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='appsettings',
name='jellyfin_server_url',
field=models.URLField(blank=True, null=True),
),
]

View File

@@ -4,6 +4,10 @@ class AppSettings(models.Model):
# Singleton-Pattern über feste ID
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
# Jellyfin
jellyfin_server_url = models.URLField(blank=True, null=True)
jellyfin_api_key = models.CharField(max_length=255, blank=True, null=True)
# Arr
sonarr_url = models.URLField(blank=True, null=True)
sonarr_api_key = models.CharField(max_length=255, blank=True, null=True)
@@ -15,7 +19,12 @@ class AppSettings(models.Model):
mail_port = models.PositiveIntegerField(blank=True, null=True)
mail_secure = models.CharField(
max_length=10, blank=True, null=True,
choices=(("", "Kein TLS/SSL"), ("starttls", "STARTTLS"), ("ssl", "SSL/TLS"))
choices=(
("", "Kein TLS/SSL"),
("starttls", "STARTTLS (Port 587)"),
("ssl", "SSL/TLS (Port 465)"),
("tls", "TLS (alias STARTTLS)"),
)
)
mail_user = models.CharField(max_length=255, blank=True, null=True)
mail_password = models.CharField(max_length=255, blank=True, null=True)
@@ -32,5 +41,15 @@ class AppSettings(models.Model):
@classmethod
def current(cls):
"""Get the current settings instance or create a new one"""
obj, _ = cls.objects.get_or_create(singleton_id=1)
return obj
def get_jellyfin_url(self):
"""Get the Jellyfin server URL with proper formatting"""
if not self.jellyfin_server_url:
return None
url = self.jellyfin_server_url
if not url.startswith(('http://', 'https://')):
url = f'http://{url}'
return url.rstrip('/')

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% load static %}
{% block extra_style %}
<link rel="stylesheet" href="{% static 'css/setup.css' %}">
{% endblock %}
{% block content %}
<div class="setup-container">
<h1>Willkommen bei Subscribarr</h1>
<p class="setup-intro">Lass uns deine Installation einrichten. Du brauchst mindestens einen Jellyfin-Server.</p>
<form method="post" class="setup-form">
{% csrf_token %}
<div class="setup-section">
<h2>Jellyfin Server (Erforderlich)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.jellyfin_server_url }}
<div class="help">z.B. http://192.168.1.100:8096 oder http://jellyfin.local:8096</div>
</div>
<div class="form-group">
<label>API Key</label>
{{ form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin-Einstellungen</div>
</div>
</div>
<div class="setup-section">
<h2>Sonarr (Optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.sonarr_url }}
</div>
<div class="form-group">
<label>API Key</label>
{{ form.sonarr_api_key }}
</div>
</div>
<div class="setup-section">
<h2>Radarr (Optional)</h2>
<div class="form-group">
<label>Server URL</label>
{{ form.radarr_url }}
</div>
<div class="form-group">
<label>API Key</label>
{{ form.radarr_api_key }}
</div>
</div>
<button type="submit" class="setup-submit">Installation abschließen</button>
</form>
</div>
{% endblock %}

View File

@@ -6,201 +6,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen Subscribarr</title>
<style>
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
* {
box-sizing: border-box
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
margin: 0
}
.wrap {
max-width: 1000px;
margin: 0 auto;
padding: 16px
}
a {
color: #cfd3ea;
text-decoration: none
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer
}
.btn-primary {
background: var(--accent);
border-color: transparent
}
.grid {
display: grid;
gap: 16px
}
@media(min-width:900px) {
.grid {
grid-template-columns: 1fr 1fr
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 14px
}
.card h2 {
margin: 0 0 10px;
font-size: 1.05rem
}
.row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
/* <= statt 160px 1fr */
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.row label {
color: #c9cbe3
}
.row input,
.row select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text)
}
.help {
color: var(--muted);
font-size: .9rem
}
.msgs {
margin-bottom: 10px
}
.msg {
background: #0f1425;
border: 1px solid #283058;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px
}
.input-wide {
width: 100% !important;
max-width: 100%;
min-width: 0;
display: block;
}
.row input,
.row select,
.row textarea {
width: 100%;
max-width: 100%;
min-width: 0;
display: block;
}
/* falls du passwort/URL Felder extra stylen willst, gleicher Fix */
.inline>input,
.inline>.django-url,
/* falls Widget eine Klasse rendert */
.inline>.django-password {
min-width: 0;
width: 100%;
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 220px;
justify-content: flex-end;
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer;
}
.btn:disabled {
opacity: .6;
cursor: default;
}
.badge {
padding: 6px 10px;
border-radius: 999px;
font-size: .85rem;
border: 1px solid #2a2a34;
background: #111119;
color: #cfd3ea;
white-space: nowrap;
/* eine Zeile */
max-width: 140px;
/* begrenzt die Breite */
overflow: hidden;
text-overflow: ellipsis;
/* falls doch Text drin ist */
}
.badge.ok {
border-color: #1f6f3a;
background: #10331f;
color: #a7e3bd;
}
.badge.err {
border-color: #6f1f2a;
background: #341016;
color: #f1a3b0;
}
.badge.muted {
opacity: .8;
}
</style>
<link rel="stylesheet" href="{% static 'css/settings.css' %}">
</head>
<body>
@@ -218,6 +24,20 @@
<form method="post">
{% csrf_token %}
<div class="grid">
<div class="card">
<h2>Jellyfin</h2>
<div class="row">
<label>Jellyfin Server URL</label>
{{ jellyfin_form.jellyfin_server_url }}
<div class="help">z.B. http://localhost:8096</div>
</div>
<div class="row">
<label>Jellyfin API Key</label>
{{ jellyfin_form.jellyfin_api_key }}
<div class="help">Admin API Key aus den Jellyfin Einstellungen</div>
</div>
</div>
<div class="card">
<h2>Sonarr & Radarr</h2>

View File

@@ -1,8 +1,9 @@
from django.urls import path
from .views import SettingsView, test_connection
from .views import SettingsView, test_connection, first_run
app_name = "settingspanel"
urlpatterns = [
path("", SettingsView.as_view(), name="index"),
path("test-connection/", test_connection, name="test_connection"),
path("setup/", first_run, name="setup"),
]

View File

@@ -1,11 +1,53 @@
from django.views import View
from django.shortcuts import render, redirect
from django.views import View
from django.contrib import messages
from .forms import ArrSettingsForm, MailSettingsForm, AccountForm
from django.utils.decorators import method_decorator
from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, FirstRunSetupForm, JellyfinSettingsForm
from .models import AppSettings
from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required
from django.contrib.auth import get_user_model
import requests
def needs_setup():
"""Check if the app needs first-run setup"""
settings = AppSettings.current()
return not bool(settings.jellyfin_server_url)
def first_run(request):
"""Handle first-run setup"""
if not needs_setup():
return redirect('arr_api:index')
if request.method == 'POST':
form = FirstRunSetupForm(request.POST)
if form.is_valid():
# Save settings
settings = AppSettings.current()
settings.jellyfin_server_url = form.cleaned_data['jellyfin_server_url']
settings.jellyfin_api_key = form.cleaned_data['jellyfin_api_key']
settings.sonarr_url = form.cleaned_data['sonarr_url']
settings.sonarr_api_key = form.cleaned_data['sonarr_api_key']
settings.radarr_url = form.cleaned_data['radarr_url']
settings.radarr_api_key = form.cleaned_data['radarr_api_key']
settings.save()
messages.success(request, 'Setup erfolgreich abgeschlossen!')
return redirect('accounts:login')
else:
form = FirstRunSetupForm()
return render(request, 'settingspanel/first_run.html', {'form': form})
from django.shortcuts import render, redirect
from django.contrib import messages
from django.utils.decorators import method_decorator
from .forms import ArrSettingsForm, MailSettingsForm, AccountForm, JellyfinSettingsForm
from .models import AppSettings
from django.http import JsonResponse
from accounts.utils import jellyfin_admin_required
import requests
@jellyfin_admin_required
def test_connection(request):
kind = request.GET.get("kind") # "sonarr" | "radarr"
url = (request.GET.get("url") or "").strip()
@@ -27,12 +69,17 @@ def test_connection(request):
except requests.RequestException as e:
return JsonResponse({"ok": False, "error": str(e)})
@method_decorator(jellyfin_admin_required, name='dispatch')
class SettingsView(View):
template_name = "settingspanel/settings.html"
def get(self, request):
cfg = AppSettings.current()
return render(request, self.template_name, {
"jellyfin_form": JellyfinSettingsForm(initial={
"jellyfin_server_url": cfg.jellyfin_server_url or "",
"jellyfin_api_key": cfg.jellyfin_api_key or "",
}),
"arr_form": ArrSettingsForm(initial={
"sonarr_url": cfg.sonarr_url or "",
"sonarr_api_key": cfg.sonarr_api_key or "",
@@ -54,15 +101,24 @@ class SettingsView(View):
})
def post(self, request):
jellyfin_form = JellyfinSettingsForm(request.POST)
arr_form = ArrSettingsForm(request.POST)
mail_form = MailSettingsForm(request.POST)
acc_form = AccountForm(request.POST)
if not (arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()):
if not (jellyfin_form.is_valid() and arr_form.is_valid() and mail_form.is_valid() and acc_form.is_valid()):
return render(request, self.template_name, {
"arr_form": arr_form, "mail_form": mail_form, "account_form": acc_form
"jellyfin_form": jellyfin_form,
"arr_form": arr_form,
"mail_form": mail_form,
"account_form": acc_form
})
cfg = AppSettings.current()
# Update Jellyfin settings
cfg.jellyfin_server_url = jellyfin_form.cleaned_data["jellyfin_server_url"] or None
cfg.jellyfin_api_key = jellyfin_form.cleaned_data["jellyfin_api_key"] or None
cfg.sonarr_url = arr_form.cleaned_data["sonarr_url"] or None
cfg.sonarr_api_key = arr_form.cleaned_data["sonarr_api_key"] or None
cfg.radarr_url = arr_form.cleaned_data["radarr_url"] or None

160
static/css/base.css Normal file
View File

@@ -0,0 +1,160 @@
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6;
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
line-height: 1.5;
}
.auth-container {
max-width: 400px;
margin: 40px auto;
padding: 20px;
}
.auth-form {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 20px;
}
.auth-form label {
display: block;
margin-bottom: 4px;
color: var(--muted);
}
.auth-form input:not([type="checkbox"]) {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
margin-bottom: 16px;
}
.auth-form input:focus {
border-color: var(--accent);
outline: none;
}
.btn-primary {
display: inline-block;
padding: 10px 20px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
}
.btn-primary:hover {
filter: brightness(1.1);
}
.auth-links {
margin-top: 20px;
text-align: center;
}
.auth-links a {
color: var(--accent);
text-decoration: none;
}
.auth-links a:hover {
text-decoration: underline;
}
.errorlist {
color: #ef4444;
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.helptext {
display: block;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 8px;
}
.main-nav {
background: var(--panel);
border-bottom: 1px solid var(--panel-b);
padding: 12px 16px;
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
color: var(--text);
text-decoration: none;
font-weight: 600;
font-size: 1.2rem;
}
.nav-links {
display: flex;
gap: 16px;
align-items: center;
}
.nav-links a,
.nav-links button {
display: flex;
align-items: center;
gap: 6px;
color: var(--text);
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
transition: background-color 0.2s;
}
.nav-links a:hover,
.nav-links button:hover {
background: rgba(255, 255, 255, 0.1);
}
.user-info {
color: var(--muted);
margin-right: 8px;
}
.nav-register {
background: var(--accent) !important;
}
.nav-register:hover {
filter: brightness(1.1);
background: var(--accent);
}
.nav-logout {
color: #ef4444 !important;
}
.nav-admin {
color: var(--accent) !important;
}

477
static/css/index.css Normal file
View File

@@ -0,0 +1,477 @@
/* Variables and base resets for the index page */
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6
}
* {
box-sizing: border-box
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 16px
}
h1 {
margin: 4px 0 12px;
font-size: clamp(1.2rem, 2.5vw, 1.6rem)
}
/* Controls */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px
}
.controls form {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 220px
}
.controls input[type=text] {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
min-width: 0
}
.controls button[type=submit] {
padding: 10px 14px;
border-radius: 10px;
border: 0;
background: var(--accent);
color: #fff;
cursor: pointer
}
.seg {
display: inline-flex;
background: #0f0f17;
border: 1px solid #28293a;
border-radius: 10px;
overflow: hidden
}
.seg a {
padding: 8px 12px;
color: var(--text);
text-decoration: none
}
.seg a.active {
background: var(--accent);
color: #fff
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px
}
@media (min-width:900px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
overflow: hidden;
display: flex;
gap: 12px;
padding: 12px;
transition: transform .08s ease, border-color .08s;
cursor: pointer
}
.card:active,
.card:hover {
transform: translateY(-2px);
border-color: #2a2b44
}
.poster {
width: 110px;
height: 165px;
background: #222233;
border-radius: 8px;
overflow: hidden;
flex: 0 0 auto
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block
}
.meta {
flex: 1 1 auto;
min-width: 0
}
.title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.episodes {
max-height: 210px;
overflow: auto;
padding-right: 6px
}
.ep {
font-size: .92rem;
padding: 6px 0;
border-bottom: 1px dashed #25263a
}
.muted {
color: var(--muted);
font-size: .9rem
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px
}
.movie-card img {
width: 100%;
border-radius: 8px;
margin-bottom: 8px;
display: block;
height: auto
}
.section {
margin-top: 22px
}
.section h2 {
font-size: 1.1rem;
margin: 12px 0
}
/* Subscription buttons */
.subscribe-btn {
display: inline-block;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
text-decoration: none;
font-size: .9rem;
cursor: pointer;
transition: all .2s ease
}
.subscribe-btn:hover {
background: var(--accent);
color: #fff
}
.subscribe-btn.subscribed {
background: var(--accent);
color: #fff
}
.subscribe-btn.unsubscribe {
border-color: #ef4444;
color: #ef4444
}
.subscribe-btn.unsubscribe:hover {
background: #ef4444;
color: #fff
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10, 12, 20, .55);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 12px
}
.modal {
width: min(960px, 100%);
max-height: 92vh;
overflow: auto;
background: linear-gradient(180deg, #10121b 0%, #0d0f16 100%);
border: 1px solid #2a2b44;
border-radius: 16px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .6)
}
.modal-header {
position: sticky;
grid-template-columns: auto 1fr auto;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: 130px 1fr auto;
gap: 14px;
align-items: center;
padding: 16px;
background: rgba(13, 15, 22, .85);
backdrop-filter: blur(4px);
border-bottom: 1px solid #20223a
}
.m-poster {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 130px;
height: 195px;
border-radius: 10px;
overflow: hidden;
background: #222233
}
.m-poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block
}
.m-title {
font-size: 1.25rem;
font-weight: 750;
line-height: 1.2;
margin-bottom: 6px
}
.m-sub {
color: var(--muted);
font-size: .92rem
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px
}
.badge {
padding: 4px 8px;
border-radius: 999px;
background: #171a26;
border: 1px solid #2a2b44;
font-size: .82rem;
color: #cfd3ea
}
.modal-close {
margin-left: auto;
align-self: start;
background: #1a1f33;
border: 1px solid #2a2b44;
color: #c9cbe3;
width: 36px;
height: 36px;
justify-self: end;
border-radius: 10px;
cursor: pointer;
display: grid;
place-items: center;
font-size: 1.4rem;
line-height: 1;
transition: transform .08s ease, background .12s ease, border-color .12s ease
}
.modal-close:hover {
background: #243055;
border-color: #3b4aa0;
transform: translateY(-1px)
}
.modal-body {
padding: 16px;
display: grid;
grid-template-columns: 1fr;
gap: 16px
}
@media (min-width:900px) {
.modal-body {
grid-template-columns: 1.2fr .8fr
}
}
.section-block {
background: #101327;
border: 1px solid #20223a;
border-radius: 12px;
padding: 14px
}
.section-title {
font-size: 1rem;
font-weight: 650;
margin: 0 0 8px
}
.section-divider {
height: 1px;
background: #20223a;
margin: 10px 0;
opacity: .9
}
.ep-row {
border-bottom: 1px dashed #262947;
padding: 8px 0;
font-size: .94rem
}
.ep-row:last-child {
border-bottom: 0
}
/* control */
.controls input[type=number] {
width: 90px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text);
font-size: 1rem;
appearance: textfield;
-moz-appearance: textfield
}
.btn-subscribe {
padding: 8px 14px;
border-radius: 10px;
background: #1f6f3a;
border: 1px solid #2a2b34;
color: #fff;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .08s ease
}
.btn-subscribe:hover {
background: #2b8f4d
}
.btn-subscribe:active {
transform: translateY(1px)
}
.subscribed {
outline: 3px solid #1f6f3a;
outline-offset: 2px
}
.controls input[type=number]::-webkit-outer-spin-button,
.controls input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0
}
.controls input[type=number]:focus {
border-color: var(--accent);
outline: none
}
.movie-card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: transform .08s ease, border-color .08s
}
.movie-card:hover,
.movie-card:focus-within {
transform: translateY(-2px);
border-color: #2a2b44
}
.movie-card:active {
transform: translateY(0)
}
/* Error toast */
.error-message {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #ef4444;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, .1);
z-index: 1000;
animation: slideUp .3s ease
}
@keyframes slideUp {
from {
transform: translate(-50%, 100%);
opacity: 0
}
to {
transform: translate(-50%, 0);
opacity: 1
}
}
/* Debug info */
.debug-info {
text-align: right;
margin: 0 16px 12px;
color: var(--muted);
font-size: .9rem
}

155
static/css/profile.css Normal file
View File

@@ -0,0 +1,155 @@
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 20px
}
.profile-section {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 20px;
margin: 20px 0
}
.subscription-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
margin-top: 12px
}
.subscription-item {
display: flex;
gap: 12px;
background: rgba(0, 0, 0, .2);
padding: 12px;
border-radius: 8px;
transition: transform .2s ease;
cursor: pointer
}
.subscription-item:hover {
transform: translateY(-2px);
background: rgba(0, 0, 0, .3)
}
.subscription-poster {
width: 80px;
height: 120px;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, .2)
}
.subscription-info {
display: flex;
flex-direction: column;
justify-content: center
}
.subscription-title {
font-weight: 600;
margin-bottom: 4px
}
.subscription-date {
font-size: .9rem;
color: var(--muted);
margin-bottom: 4px
}
.subscription-overview {
font-size: .9rem;
color: var(--muted);
line-height: 1.4;
opacity: .9
}
.messages {
margin-bottom: 20px
}
.message {
padding: 12px;
border-radius: 8px;
margin-bottom: 8px
}
.message.success {
background: #1f6f3a;
color: #fff
}
.message.error {
background: #ef4444;
color: #fff
}
.btn-secondary {
display: inline-block;
padding: 8px 16px;
background: #1f2030;
color: var(--text);
text-decoration: none;
border-radius: 8px;
border: 1px solid #2a2b44
}
.btn-secondary:hover {
background: #2a2b44
}
.mt-4 {
margin-top: 16px
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: .875rem;
font-weight: 500
}
.badge-admin {
background-color: #1f6f3a;
color: #fff
}
.jellyfin-info {
margin-top: 24px;
padding: 16px;
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 8px
}
.jellyfin-info h4 {
margin: 0 0 12px 0;
color: var(--accent)
}
.compact-form .form-row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
align-items: center;
margin-bottom: 10px
}
.text-input {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text)
}
.text-input:focus {
border-color: var(--accent);
outline: none
}

178
static/css/settings.css Normal file
View File

@@ -0,0 +1,178 @@
:root {
--bg: #0b0b10;
--panel: #12121a;
--panel-b: #1f2030;
--accent: #3b82f6;
--muted: #9aa0b4;
--text: #e6e6e6
}
* {
box-sizing: border-box
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, Segoe UI, Roboto, Arial, sans-serif;
margin: 0
}
.wrap {
max-width: 1000px;
margin: 0 auto;
padding: 16px
}
a {
color: #cfd3ea;
text-decoration: none
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: #fff;
cursor: pointer
}
.btn-primary {
background: var(--accent);
border-color: transparent
}
.grid {
display: grid;
gap: 16px
}
@media(min-width:900px) {
.grid {
grid-template-columns: 1fr 1fr
}
}
.card {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 14px
}
.card h2 {
margin: 0 0 10px;
font-size: 1.05rem
}
.row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
margin-bottom: 10px
}
.row label {
color: #c9cbe3
}
.row input,
.row select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a2a34;
background: #111119;
color: var(--text)
}
.help {
color: var(--muted);
font-size: .9rem
}
.msgs {
margin-bottom: 10px
}
.msg {
background: #0f1425;
border: 1px solid #283058;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px
}
.input-wide {
width: 100% !important;
max-width: 100%;
min-width: 0;
display: block
}
.row input,
.row select,
.row textarea {
width: 100%;
max-width: 100%;
min-width: 0;
display: block
}
.inline>input,
.inline>.django-url,
.inline>.django-password {
min-width: 0;
width: 100%
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 220px;
justify-content: flex-end
}
.btn:disabled {
opacity: .6;
cursor: default
}
.badge {
padding: 6px 10px;
border-radius: 999px;
font-size: .85rem;
border: 1px solid #2a2a34;
background: #111119;
color: #cfd3ea;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis
}
.badge.ok {
border-color: #1f6f3a;
background: #10331f;
color: #a7e3bd
}
.badge.err {
border-color: #6f1f2a;
background: #341016;
color: #f1a3b0
}
.badge.muted {
opacity: .8
}

71
static/css/setup.css Normal file
View File

@@ -0,0 +1,71 @@
.setup-container {
max-width: 800px;
margin: 40px auto;
padding: 20px
}
.setup-intro {
color: var(--muted);
font-size: 1.1em;
margin-bottom: 30px
}
.setup-section {
background: var(--panel);
border: 1px solid var(--panel-b);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px
}
.setup-section h2 {
margin: 0 0 20px;
font-size: 1.2em
}
.form-group {
margin-bottom: 16px
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--muted)
}
.form-group input {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--panel-b);
background: rgba(0, 0, 0, .2);
color: var(--text)
}
.form-group input:focus {
outline: none;
border-color: var(--accent)
}
.help {
margin-top: 4px;
font-size: .9em;
color: var(--muted)
}
.setup-submit {
display: block;
width: 100%;
padding: 12px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 1.1em;
cursor: pointer;
transition: filter .2s
}
.setup-submit:hover {
filter: brightness(1.1)
}

View File

@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -20,18 +21,17 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-p^2cgzteh=p3ppya71l7+r3nuc=_w@2#fer4n7ckywt1$x%u&j'
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-p^2cgzteh=p3ppya71l7+r3nuc=_w@2#fer4n7ckywt1$x%u&j')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = os.getenv('DJANGO_DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = []
ALLOWED_HOSTS = [h for h in os.getenv('DJANGO_ALLOWED_HOSTS', '').split(',') if h] or []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
'rest_framework',
'arr_api',
'settingspanel',
'accounts',
]
MIDDLEWARE = [
@@ -50,6 +51,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'settingspanel.middleware.SetupMiddleware',
]
ROOT_URLCONF = 'subscribarr.urls'
@@ -57,7 +59,7 @@ ROOT_URLCONF = 'subscribarr.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -75,10 +77,11 @@ WSGI_APPLICATION = 'subscribarr.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
_db_path = os.getenv('DB_PATH')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'NAME': _db_path if _db_path else BASE_DIR / 'db.sqlite3',
}
}
@@ -105,9 +108,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'de-de'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Europe/Berlin'
USE_I18N = True
@@ -118,8 +121,39 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
# STATIC_ROOT could be set for collectstatic in production
# STATIC_ROOT = BASE_DIR / 'staticfiles'
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Custom User Model
AUTH_USER_MODEL = 'accounts.User'
# Login-URLs
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
# Default Jellyfin Settings - will be overridden by database settings
JELLYFIN_CLIENT = 'Subscribarr'
JELLYFIN_VERSION = '10.10.7'
JELLYFIN_DEVICE = 'Subscribarr'
JELLYFIN_DEVICE_ID = 'subscribarr-instance'
# Email Settings - override these with settings from the database
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = None # Will be set from AppSettings
EMAIL_PORT = None # Will be set from AppSettings
EMAIL_USE_TLS = False
EMAIL_USE_SSL = False
EMAIL_HOST_USER = None # Will be set from AppSettings
EMAIL_HOST_PASSWORD = None # Will be set from AppSettings
DEFAULT_FROM_EMAIL = None # Will be set from AppSettings
# Notifications / Debug
# If True, duplicate suppression is disabled and emails can be resent on every run.
NOTIFICATIONS_ALLOW_DUPLICATES = os.getenv('NOTIFICATIONS_ALLOW_DUPLICATES', 'True').lower() == 'true'

View File

@@ -14,13 +14,10 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from arr_api.views import ArrIndexView
urlpatterns = [
path('admin/', admin.site.urls),
path("", ArrIndexView.as_view(), name="home"),
path("settings/", include("settingspanel.urls")),
path("api/", include("arr_api.urls")),
path('', include('arr_api.urls')),
path('settings/', include('settingspanel.urls')),
path('accounts/', include('accounts.urls', namespace='accounts')),
]

73
templates/base.html Normal file
View File

@@ -0,0 +1,73 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Subscribarr{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block extra_style %}{% endblock %}
</head>
<body>
<nav class="main-nav">
<div class="nav-content">
<a href="/" class="nav-brand">Subscribarr</a>
<div class="nav-links">
{% if user.is_authenticated %}
<span class="user-info">Angemeldet als <strong>{{ user.username }}</strong></span>
<a href="{% url 'accounts:profile' %}" class="nav-profile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
Profil
</a>
{% if user.is_jellyfin_admin %}
<a href="{% url 'settingspanel:index' %}" class="nav-admin">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20V10M12 10l-4-4m4 4l4-4M4 16.667V20h16v-3.333M4 7.333V4h16v3.333"></path>
</svg>
Administration
</a>
{% endif %}
<form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="nav-logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Abmelden
</button>
</form>
{% else %}
<a href="{% url 'accounts:login' %}" class="nav-login">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
Anmelden
</a>
<a href="{% url 'accounts:register' %}" class="nav-register">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="8.5" cy="7" r="4"></circle>
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
Registrieren
</a>
{% endif %}
</div>
</div>
</nav>
{% block content %}{% endblock %}
</body>
</html>