Compare commits

..

1 Commits

Author SHA1 Message Date
11193677cf update README 2025-08-15 13:39:51 +02:00
5 changed files with 154 additions and 129 deletions

View File

@@ -4,7 +4,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License MIT"></a> <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/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/django-5.x-092e20?logo=django&logoColor=white" alt="Django 5">
<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/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker ready">
<img src="https://img.shields.io/badge/ntfy-supported-4c1" alt="ntfy supported"> <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"> <img src="https://img.shields.io/badge/Apprise-supported-4c1" alt="Apprise supported">
</p> </p>
@@ -43,17 +43,14 @@ Lightweight web UI for Sonarr/Radarr subscriptions with Jellyfin login, calendar
## Quickstart (Docker Compose) ## Quickstart (Docker Compose)
1) Ensure the lockfile matches your Pipfile (e.g., after adding packages): 1) Ensure the lockfile matches your Pipfile (e.g., after adding packages):
```bash ```bash
git clone https://github.com/jschaufuss/subscribarr.git pipenv lock
cd subscribarr
``` ```
2) run the container: 2) Build and run:
```bash ```bash
docker compose build
docker compose up -d docker compose up -d
``` ```
3) Open the app and complete the firstrun setup (Jellyfin + Arr URLs/keys). 3) Open the app and complete the firstrun setup (Jellyfin + Arr URLs/keys).
```
http://127.0.0.1:8081
```
Important environment variables (examples): Important environment variables (examples):
- `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1` - `DJANGO_ALLOWED_HOSTS=subscribarr.example.com,localhost,127.0.0.1`
@@ -66,6 +63,13 @@ Important environment variables (examples):
> Note: `DJANGO_CSRF_TRUSTED_ORIGINS` must include the exact scheme+host (+port if used). > 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
```
## InApp Configuration ## InApp Configuration
- Settings → Jellyfin: server URL + API key - Settings → Jellyfin: server URL + API key
- Settings → Sonarr/Radarr: base URLs + API keys (with “Test” button) - Settings → Sonarr/Radarr: base URLs + API keys (with “Test” button)
@@ -97,15 +101,19 @@ Provide one or more destination URLs (one per line), e.g.:
User URLs are added in addition to global defaults. User URLs are added in addition to global defaults.
## Notification Logic ## Notification Logic
- Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when episode is downloaded and present. - Series: on the air date, Subscribarr checks Sonarr for the episode and only notifies when `hasFile` is true (downloaded/present).
- Movies: similar via Radarr when movie is downloaded and present. - Movies: similar via Radarr `hasFile` and matching the release date (Digital/Disc/Cinema) for today.
- Duplicate suppression: entries are recorded in `SentNotification` per user/title/day; if sending fails, no record is stored. - 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). - Fallback: if ntfy/Apprise fail, Subscribarr falls back to Email (when configured).
## Jobs / Manual Trigger ## Jobs / Manual Trigger
- Periodic check via management command (e.g., cron): - Periodic check via management command (e.g., cron):
```bash ```bash
docker exec -it subscribarr python manage.py check_new_media pipenv run python manage.py check_new_media
```
- In Docker:
```bash
docker compose exec web python manage.py check_new_media
``` ```
## Security & Proxy ## Security & Proxy
@@ -123,4 +131,3 @@ docker exec -it subscribarr python manage.py check_new_media
## License ## License
MIT (see `LICENSE`). MIT (see `LICENSE`).

View File

@@ -1,44 +0,0 @@
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}"))

View File

@@ -7,7 +7,6 @@ from settingspanel.models import AppSettings
import requests import requests
from dateutil.parser import isoparse from dateutil.parser import isoparse
import logging import logging
from django.db import transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -178,8 +177,6 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message
return True return True
# fallback to email # fallback to email
try: try:
# Ensure email backend is configured from AppSettings at runtime
_set_runtime_email_settings()
send_mail( send_mail(
subject=subject, subject=subject,
message=body_text, message=body_text,
@@ -329,10 +326,21 @@ def check_and_notify_users():
if season is None or number is None: if season is None or number is None:
continue continue
# duplicate guard will be handled atomically before dispatch # 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 # check availability via Sonarr hasFile
if sonarr_episode_has_file(sub.series_id, season, number): if sonarr_episode_has_file(sub.series_id, season, number):
if not sub.user.email:
continue
# Build subject/body # Build subject/body
subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}" 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." body = f"{sub.series_title} S{season:02d}E{number:02d} is now available."
@@ -353,48 +361,36 @@ def check_and_notify_users():
html = render_to_string('arr_api/email/new_media_notification.html', ctx) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception: except Exception:
pass 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) ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): # mark as sent unless duplicates are allowed
# allow retry on next run if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
try: SentNotification.objects.create(
SentNotification.objects.filter(
user=sub.user, user=sub.user,
media_id=sub.series_id, media_id=sub.series_id,
media_type='series', media_type='series',
media_title=sub.series_title,
air_date=today air_date=today
).delete() )
except Exception:
pass
# Film-Abos # Film-Abos
for sub in MovieSubscription.objects.select_related('user').all(): for sub in MovieSubscription.objects.select_related('user').all():
it = movie_idx.get(sub.movie_id) 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: if not it:
continue 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 radarr_movie_has_file(sub.movie_id):
if not sub.user.email:
continue
# detect which release matched today # detect which release matched today
rel = None rel = None
try: try:
@@ -427,32 +423,15 @@ def check_and_notify_users():
html = render_to_string('arr_api/email/new_media_notification.html', ctx) html = render_to_string('arr_api/email/new_media_notification.html', ctx)
except Exception: except Exception:
pass 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) ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html)
if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False):
try: SentNotification.objects.create(
SentNotification.objects.filter(
user=sub.user, user=sub.user,
media_id=sub.movie_id, media_id=sub.movie_id,
media_type='movie', media_type='movie',
media_title=sub.title,
air_date=today air_date=today
).delete() )
except Exception:
pass
def has_new_episode_today(series_id): def has_new_episode_today(series_id):

View File

@@ -81,13 +81,9 @@ class ArrIndexView(View):
eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] 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()] movies = [m for m in movies if q in (m.get("title") or "").lower()]
# Abonnierte Serien und Filme pro aktuellem Nutzer # Abonnierte Serien und Filme laden
if request.user.is_authenticated: subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True))
subscribed_series_ids = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)) subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_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 # Gruppierung nach Serie
groups = defaultdict(lambda: { groups = defaultdict(lambda: {
@@ -201,7 +197,70 @@ class CalendarEventsApi(APIView):
return Response({"events": events}) 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 @require_POST

View File

@@ -1,15 +1,39 @@
version: '3.8' version: '3.8'
services: services:
subscribarr: subscribarr:
image: 10010011/subscribarr:latest build: .
container_name: subscribarr container_name: subscribarr
ports: ports:
- "8081:8000" - "8081:8000"
environment: 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_ALLOWED_HOSTS=*
- DJANGO_SECRET_KEY=change-me - DJANGO_SECRET_KEY=change-me
- DB_PATH=/app/data/db.sqlite3
- NOTIFICATIONS_ALLOW_DUPLICATES=false - NOTIFICATIONS_ALLOW_DUPLICATES=false
- DJANGO_CSRF_TRUSTED_ORIGINS="https://subscribarr.local.js-devop.de" - 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 (default every 30min)
- CRON_SCHEDULE=*/30 * * * * - CRON_SCHEDULE=*/30 * * * *
volumes: volumes: