Compare commits
6 Commits
fb9b8390a3
...
main
Author | SHA1 | Date | |
---|---|---|---|
2a62f37b8c | |||
00d7fe60d5 | |||
ee3ebeeb93 | |||
39bcd35925 | |||
c03606e31d | |||
b36f42a7b9 |
35
README.md
35
README.md
@@ -4,7 +4,7 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License MIT"></a>
|
||||
<img src="https://img.shields.io/badge/python-3.13-blue.svg" alt="Python 3.13">
|
||||
<img src="https://img.shields.io/badge/django-5.x-092e20?logo=django&logoColor=white" alt="Django 5">
|
||||
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker ready">
|
||||
<a href="https://hub.docker.com/r/10010011/subscribarr"><img src="https://img.shields.io/docker/pulls/10010011/subscribarr" alt="docker pulls"></a>
|
||||
<img src="https://img.shields.io/badge/ntfy-supported-4c1" alt="ntfy supported">
|
||||
<img src="https://img.shields.io/badge/Apprise-supported-4c1" alt="Apprise supported">
|
||||
</p>
|
||||
@@ -43,14 +43,17 @@ Lightweight web UI for Sonarr/Radarr subscriptions with Jellyfin login, calendar
|
||||
## Quickstart (Docker Compose)
|
||||
1) Ensure the lockfile matches your Pipfile (e.g., after adding packages):
|
||||
```bash
|
||||
pipenv lock
|
||||
git clone https://github.com/jschaufuss/subscribarr.git
|
||||
cd subscribarr
|
||||
```
|
||||
2) Build and run:
|
||||
2) run the container:
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
3) Open the app and complete the first‑run setup (Jellyfin + Arr URLs/keys).
|
||||
```
|
||||
http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Important environment variables (examples):
|
||||
- `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1`
|
||||
@@ -63,18 +66,11 @@ Important environment variables (examples):
|
||||
|
||||
> Note: `DJANGO_CSRF_TRUSTED_ORIGINS` must include the exact scheme+host (+port if used).
|
||||
|
||||
### Local (Pipenv)
|
||||
```bash
|
||||
pipenv sync
|
||||
pipenv run python manage.py migrate
|
||||
pipenv run python manage.py runserver
|
||||
```
|
||||
|
||||
## In‑App Configuration
|
||||
- Settings → Jellyfin: server URL + API key
|
||||
- Settings → Sonarr/Radarr: base URLs + API keys (with “Test” button)
|
||||
- Settings → Mail server: SMTP (host/port/TLS/SSL/user/password/from)
|
||||
- Settings → Notifications:
|
||||
- Settings →Sonarr/Radarr: base URLs + API keys (with “Test” button)
|
||||
- Settings →Mail server: SMTP (host/port/TLS/SSL/user/password/from)
|
||||
- Settings →Notifications:
|
||||
- ntfy: server URL, default topic, Basic Auth or Bearer token
|
||||
- Apprise: default URL(s) (one per line)
|
||||
- Profile (per user):
|
||||
@@ -101,19 +97,15 @@ Provide one or more destination URLs (one per line), e.g.:
|
||||
User URLs are added in addition to global defaults.
|
||||
|
||||
## Notification Logic
|
||||
- Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when `hasFile` is true (downloaded/present).
|
||||
- Movies: similar via Radarr `hasFile` and matching the release date (Digital/Disc/Cinema) for today.
|
||||
- Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when episode is downloaded and present.
|
||||
- Movies: similar via Radarr when movie is downloaded and present.
|
||||
- Duplicate suppression: entries are recorded in `SentNotification` per user/title/day; if sending fails, no record is stored.
|
||||
- Fallback: if ntfy/Apprise fail, Subscribarr falls back to Email (when configured).
|
||||
|
||||
## Jobs / Manual Trigger
|
||||
- Periodic check via management command (e.g., cron):
|
||||
```bash
|
||||
pipenv run python manage.py check_new_media
|
||||
```
|
||||
- In Docker:
|
||||
```bash
|
||||
docker compose exec web python manage.py check_new_media
|
||||
docker exec -it subscribarr python manage.py check_new_media
|
||||
```
|
||||
|
||||
## Security & Proxy
|
||||
@@ -131,3 +123,4 @@ docker compose exec web python manage.py check_new_media
|
||||
|
||||
## License
|
||||
MIT (see `LICENSE`).
|
||||
|
||||
|
44
arr_api/management/commands/send_test_email.py
Normal file
44
arr_api/management/commands/send_test_email.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from arr_api.notifications import send_notification_email
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Send a test notification email to verify SMTP configuration"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--to', required=True, help='Recipient email address')
|
||||
parser.add_argument('--username', default='testuser', help='Username to associate with the email')
|
||||
parser.add_argument('--type', default='movie', choices=['movie', 'series'], help='Media type for the template')
|
||||
parser.add_argument('--title', default='Subscribarr Test', help='Title to show in the email')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
User = get_user_model()
|
||||
email = opts['to']
|
||||
username = opts['username']
|
||||
media_type = opts['type']
|
||||
title = opts['title']
|
||||
|
||||
user, _ = User.objects.get_or_create(username=username, defaults={'email': email})
|
||||
if user.email != email:
|
||||
user.email = email
|
||||
user.save(update_fields=['email'])
|
||||
|
||||
# Use current time as air_date for nicer formatting
|
||||
send_notification_email(
|
||||
user=user,
|
||||
media_title=title,
|
||||
media_type=media_type,
|
||||
overview='This is a test email from Subscribarr to verify your mail settings.',
|
||||
poster_url=None,
|
||||
episode_title='Pilot' if media_type == 'series' else None,
|
||||
season=1 if media_type == 'series' else None,
|
||||
episode=1 if media_type == 'series' else None,
|
||||
air_date=timezone.now(),
|
||||
year=timezone.now().year if media_type == 'movie' else None,
|
||||
release_type='Test'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Test email queued/sent to {email}"))
|
@@ -7,6 +7,7 @@ from settingspanel.models import AppSettings
|
||||
import requests
|
||||
from dateutil.parser import isoparse
|
||||
import logging
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -177,6 +178,8 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message
|
||||
return True
|
||||
# fallback to email
|
||||
try:
|
||||
# Ensure email backend is configured from AppSettings at runtime
|
||||
_set_runtime_email_settings()
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=body_text,
|
||||
@@ -326,21 +329,10 @@ def check_and_notify_users():
|
||||
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
|
||||
# duplicate guard will be handled atomically before dispatch
|
||||
|
||||
# check availability via Sonarr hasFile
|
||||
if sonarr_episode_has_file(sub.series_id, season, number):
|
||||
if not sub.user.email:
|
||||
continue
|
||||
# Build subject/body
|
||||
subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}"
|
||||
body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
|
||||
@@ -361,36 +353,48 @@ def check_and_notify_users():
|
||||
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
||||
except Exception:
|
||||
pass
|
||||
# Reserve duplicate token atomically, then dispatch; rollback on failure
|
||||
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
token, created = SentNotification.objects.get_or_create(
|
||||
user=sub.user,
|
||||
media_id=sub.series_id,
|
||||
media_type='series',
|
||||
air_date=today,
|
||||
defaults={'media_title': sub.series_title}
|
||||
)
|
||||
if not created:
|
||||
continue
|
||||
except Exception:
|
||||
# race or DB error -> skip to avoid duplicates
|
||||
continue
|
||||
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
||||
# mark as sent unless duplicates are allowed
|
||||
if ok and 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
|
||||
)
|
||||
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||
# allow retry on next run
|
||||
try:
|
||||
SentNotification.objects.filter(
|
||||
user=sub.user,
|
||||
media_id=sub.series_id,
|
||||
media_type='series',
|
||||
air_date=today
|
||||
).delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Film-Abos
|
||||
for sub in MovieSubscription.objects.select_related('user').all():
|
||||
it = movie_idx.get(sub.movie_id)
|
||||
# Fallback: if movie_id missing, try match by title
|
||||
if not it and getattr(sub, 'title', None):
|
||||
for _mid, _it in movie_idx.items():
|
||||
if (_it.get('title') or '').strip().lower() == (sub.title or '').strip().lower():
|
||||
it = _it
|
||||
break
|
||||
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:
|
||||
@@ -423,15 +427,32 @@ def check_and_notify_users():
|
||||
html = render_to_string('arr_api/email/new_media_notification.html', ctx)
|
||||
except Exception:
|
||||
pass
|
||||
# Reserve duplicate token atomically, then dispatch; rollback on failure
|
||||
if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
token, created = SentNotification.objects.get_or_create(
|
||||
user=sub.user,
|
||||
media_id=sub.movie_id,
|
||||
media_type='movie',
|
||||
air_date=today,
|
||||
defaults={'media_title': sub.title}
|
||||
)
|
||||
if not created:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
|
||||
if ok and 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
|
||||
)
|
||||
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
|
||||
try:
|
||||
SentNotification.objects.filter(
|
||||
user=sub.user,
|
||||
media_id=sub.movie_id,
|
||||
media_type='movie',
|
||||
air_date=today
|
||||
).delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def has_new_episode_today(series_id):
|
||||
|
@@ -81,9 +81,13 @@ 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))
|
||||
# Abonnierte Serien und Filme pro aktuellem Nutzer
|
||||
if request.user.is_authenticated:
|
||||
subscribed_series_ids = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True))
|
||||
subscribed_movie_ids = set(MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True))
|
||||
else:
|
||||
subscribed_series_ids = set()
|
||||
subscribed_movie_ids = set()
|
||||
|
||||
# Gruppierung nach Serie
|
||||
groups = defaultdict(lambda: {
|
||||
@@ -197,70 +201,7 @@ class CalendarEventsApi(APIView):
|
||||
return Response({"events": events})
|
||||
|
||||
|
||||
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'Subscribed to series "{series_data["series_title"]}"!')
|
||||
else:
|
||||
messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.')
|
||||
|
||||
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'Subscription for "{series_title}" has been removed.')
|
||||
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'Subscribed to movie "{movie_data["title"]}"!')
|
||||
else:
|
||||
messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.')
|
||||
|
||||
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'Subscription for "{movie_title}" has been removed.')
|
||||
return redirect('arr_api:index')
|
||||
|
||||
|
||||
|
||||
@require_POST
|
||||
|
@@ -1,39 +1,15 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
subscribarr:
|
||||
build: .
|
||||
image: 10010011/subscribarr:latest
|
||||
container_name: subscribarr
|
||||
ports:
|
||||
- "8081:8000"
|
||||
environment:
|
||||
# Django
|
||||
- DJANGO_DEBUG=true
|
||||
- USE_X_FORWARDED_HOST=true
|
||||
- DJANGO_SECURE_PROXY_SSL_HEADER=true
|
||||
- DJANGO_CSRF_COOKIE_SECURE=true
|
||||
- DJANGO_SESSION_COOKIE_SECURE=true
|
||||
- DJANGO_ALLOWED_HOSTS=*
|
||||
- DJANGO_SECRET_KEY=change-me
|
||||
- DB_PATH=/app/data/db.sqlite3
|
||||
- NOTIFICATIONS_ALLOW_DUPLICATES=false
|
||||
- DJANGO_CSRF_TRUSTED_ORIGINS="https://subscribarr.local.js-devop.de"
|
||||
# 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:
|
||||
|
@@ -1,3 +0,0 @@
|
||||
Jan Schaufuss <jschaufuss@js-devop.de> <jschaufuss@leitwerk.de>
|
||||
Jan Schaufuss <jschaufuss@js-devop.de> Jan Schaufuss <jschaufuss@leitwerk.de>
|
||||
<jschaufuss@js-devop.de> <jschaufuss@leitwerk.de>
|
Reference in New Issue
Block a user