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