mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 19:14:00 +00:00
commit
9dc7b241b8
16 changed files with 4411 additions and 2349 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Schedules Direct Extra Debugging option.** EPG source settings include an **Extra Schedules Direct Debugging** toggle that adds a `RouteTo: debug` header so Schedules Direct support can steer traffic to their debug server. The tooltip states it should only be enabled when SD support asks. If SD returns code 2055 (unexpected debug connection), the toggle is turned off automatically.
|
||||
|
||||
### Changed
|
||||
|
||||
- **EPG source form places Auto-Apply EPG Logos in the shared middle column for XMLTV and Schedules Direct.** The toggle previously lived in the SD-only right panel when editing a Schedules Direct source; it now uses the same middle-column control as XMLTV so SD-specific options (logo style, posters, debug) stay in the right panel.
|
||||
- **Schedules Direct poster proxy shares auth tokens across uWSGI workers via Redis.** Tokens are stored in Django's cache until near `tokenExpires` (24h fallback), so concurrent `/poster/` requests on different workers reuse one SD session instead of each process hitting `/token` separately. Redis failures fall back to re-authentication.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Schedules Direct poster proxy no longer ignores daily image download limits or missing-image errors.** SD returns codes 5002/5003 as HTTP 200 with a JSON body; the proxy previously only inspected HTTP 400 and kept requesting images after the limit, which can get accounts blocked. It now detects 5002/5003 (subscriber and trial), stops further image fetches for that source until the next midnight UTC (persisted on the EPG source so all workers honor it), and on explicit IMAGE_NOT_FOUND (code 5000) clears that program's `sd_icon` so the same dead URI is not retried. Transient bare HTTP 404s do not blacklist the URI. Successful SD posters use a dedicated nginx cache under `/data/cache/sd_posters` (14 days); channel/VOD logos remain at `/data/cache/logos` (24 hours). On startup, an existing `/data/logo_cache` directory is moved to `/data/cache/logos` once so warm logo entries are kept. API and XMLTV icon URLs include a `?v=` hash of the stored SD image URI so a new artwork link after refresh uses a new cache key without waiting for TTL expiry. Selecting programs that still need artwork no longer uses a negated JSON `AND` that dropped every row when `sd_icon_missing` / `sd_poster_style` keys were absent (Postgres NULL), which had prevented poster links from being saved after refresh.
|
||||
- **Schedules Direct auth and lineup change handling better match SD guidelines.** Token codes 3000/3001 (offline/busy) stop as idle with clear "do not retry" messaging instead of looking like credential failures; 4010 (too many unique IPs) and related account codes get explicit messages. Lineup add/delete respects a known daily change lockout before calling SD, handles 4100 on deletes, and the UI blocks remove when no changes remain (adds and deletes both count toward the 6/day limit).
|
||||
- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. That helper reuses a Redis-cached token until near `tokenExpires` (bound to a credential fingerprint, and cleared when the EPG source username/password change) so opening the SD form, refreshing EPG, and serving posters do not mint a new token on every call. When an authenticated SD call returns HTTP 401/403 (SD documents TOKEN_EXPIRED as 403), the cached token is cleared and the request is retried once with a fresh `/token`.
|
||||
- **Schedules Direct code split out of general EPG modules.** Refresh pipeline and Celery tasks live in `apps/epg/sd_tasks.py`; lineup/poster API mixins in `apps/epg/sd_api.py`; shared protocol helpers remain in `sd_utils.py`. Progress WebSocket updates (`send_epg_update`) live in `apps/epg/utils.py` so XMLTV and SD tasks can import cleanly. `tasks.py` / `api_views.py` re-export or inherit so existing imports and Celery task names are unchanged.
|
||||
|
||||
## [0.28.1] - 2026-07-20
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
|
@ -8,6 +7,10 @@ from rest_framework.pagination import PageNumberPagination
|
|||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from apps.epg.sd_api import (
|
||||
SchedulesDirectPosterMixin,
|
||||
SchedulesDirectSourceMixin,
|
||||
)
|
||||
from rest_framework.decorators import action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
|
@ -40,7 +43,7 @@ logger = logging.getLogger(__name__)
|
|||
# ─────────────────────────────
|
||||
# 1) EPG Source API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||
class EPGSourceViewSet(SchedulesDirectSourceMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows EPG sources to be viewed or edited.
|
||||
"""
|
||||
|
|
@ -125,368 +128,20 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
def _sd_authenticate(self, source):
|
||||
"""
|
||||
Authenticate with Schedules Direct using stored credentials.
|
||||
Returns (token, None) on success or (None, Response) on failure.
|
||||
"""
|
||||
import hashlib
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
username = (source.username or '').strip()
|
||||
password = (source.password or '').strip()
|
||||
if not username or not password:
|
||||
return None, Response(
|
||||
{"error": "Username and password are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_response = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=dispatcharr_http_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
auth_response.raise_for_status()
|
||||
token = auth_response.json().get('token')
|
||||
if not token:
|
||||
return None, Response(
|
||||
{"error": "Authentication failed. Check your credentials."},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
return token, None
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return None, Response(
|
||||
{"error": f"Authentication failed: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
def _get_sd_reset_at(self, source):
|
||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||
return reset_at_str
|
||||
|
||||
def _get_sd_changes_remaining(self, source):
|
||||
"""
|
||||
Retrieve stored changesRemaining from EPGSource model field.
|
||||
If a reset timestamp exists and has passed (midnight UTC), clears the
|
||||
lockout automatically so the user can make adds again.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
changes_remaining = cp.get('sd_changes_remaining')
|
||||
reset_at_str = cp.get('sd_changes_reset_at')
|
||||
from django.utils.dateparse import parse_datetime
|
||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||
|
||||
# If we have a reset timestamp and it has passed, clear the lockout
|
||||
if changes_remaining == 0 and reset_at:
|
||||
if timezone.now() >= reset_at:
|
||||
cp = source.custom_properties or {}
|
||||
cp.pop('sd_changes_remaining', None)
|
||||
cp.pop('sd_changes_reset_at', None)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
return None
|
||||
|
||||
return changes_remaining
|
||||
|
||||
def _save_sd_changes_remaining(self, source, changes_remaining):
|
||||
"""Persist changesRemaining to EPGSource model field."""
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = changes_remaining
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
def _save_sd_lockout(self, source):
|
||||
"""
|
||||
Persist a hard lockout to EPGSource custom_properties when SD returns
|
||||
4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at
|
||||
00:00Z (midnight UTC) per SD's documented behavior — error 4100 states
|
||||
"lineup changes for today" and all SD rate counters reset at midnight UTC.
|
||||
Lockout clears automatically when the next midnight UTC passes.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
now = timezone.now()
|
||||
# Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window
|
||||
tomorrow = (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0,
|
||||
tzinfo=dt_timezone.utc
|
||||
)
|
||||
reset_at = tomorrow
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = 0
|
||||
cp['sd_changes_reset_at'] = reset_at.isoformat()
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
logger.warning(
|
||||
f"SD source {source.id}: daily add limit reached (4100). "
|
||||
f"Lockout set until {reset_at.isoformat()}."
|
||||
)
|
||||
|
||||
def _fetch_sd_countries(self):
|
||||
"""Fetch the SD country list (token not required; User-Agent is)."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/available/countries",
|
||||
headers=dispatcharr_http_headers(content_type=None),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Failed to fetch SD countries: {e}")
|
||||
return None
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
||||
def sd_lineups(self, request, pk=None):
|
||||
"""
|
||||
GET — list lineups currently on the SD account
|
||||
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = dispatcharr_http_headers(token=token)
|
||||
|
||||
if request.method == "GET":
|
||||
countries = self._fetch_sd_countries()
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/lineups",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 4102:
|
||||
return Response({
|
||||
"lineups": [],
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
||||
"countries": countries,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
||||
return Response({
|
||||
"lineups": lineups,
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"countries": countries,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to fetch lineups: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "POST":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.put(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
|
||||
if resp.status_code == 400 or resp.status_code == 403:
|
||||
if sd_code == 4100:
|
||||
self._save_sd_lockout(source)
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.",
|
||||
"changes_remaining": 0,
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 4101:
|
||||
return Response({
|
||||
"error": "max_lineups_reached",
|
||||
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 2100:
|
||||
return Response({
|
||||
"error": "duplicate_lineup",
|
||||
"message": "This lineup is already on your Schedules Direct account.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response({
|
||||
"error": sd_data.get('message', 'Failed to add lineup.'),
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
# Persist changesRemaining to custom_properties
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
|
||||
logger.info(
|
||||
f"SD lineup added for source {source.id}: {lineup_id}. "
|
||||
f"changesRemaining: {changes_remaining}"
|
||||
)
|
||||
|
||||
# Re-fetch stations so the new lineup's stations are available for matching
|
||||
from apps.epg.tasks import fetch_schedules_direct_stations
|
||||
fetch_schedules_direct_stations.delay(source.id)
|
||||
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": changes_remaining,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to add lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "DELETE":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.delete(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 2103:
|
||||
return Response({
|
||||
"response": "OK",
|
||||
"code": 0,
|
||||
"message": "Lineup not found on account — already removed.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
resp.raise_for_status()
|
||||
sd_data = resp.json()
|
||||
# SD returns changesRemaining on deletes — persist it
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to remove lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
||||
def sd_lineups_search(self, request, pk=None):
|
||||
"""
|
||||
Search available headends/lineups by country and postal code.
|
||||
Body: {"country": "USA", "postalcode": "07030"}
|
||||
Returns a flat list of lineups across all matching headends.
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
country = request.data.get('country', '').strip()
|
||||
postalcode = request.data.get('postalcode', '').strip()
|
||||
if not country or not postalcode:
|
||||
return Response(
|
||||
{"error": "country and postalcode are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = dispatcharr_http_headers(token=token)
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/headends",
|
||||
params={'country': country, 'postalcode': postalcode},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
headends = resp.json()
|
||||
lineups = []
|
||||
for headend in headends:
|
||||
for lineup in headend.get('lineups', []):
|
||||
lineups.append({
|
||||
'lineup': lineup.get('lineup'),
|
||||
'name': lineup.get('name'),
|
||||
'transport': headend.get('transport'),
|
||||
'location': headend.get('location'),
|
||||
'headend': headend.get('headend'),
|
||||
})
|
||||
return Response({"lineups": lineups})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to search headends: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class ProgramSearchPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 500
|
||||
|
||||
|
||||
class ProgramViewSet(viewsets.ModelViewSet):
|
||||
class ProgramViewSet(SchedulesDirectPosterMixin, viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for EPG programs"""
|
||||
|
||||
queryset = ProgramData.objects.select_related("epg").all()
|
||||
serializer_class = ProgramDataSerializer
|
||||
|
||||
# Per-source in-memory caches (token and error state)
|
||||
_sd_poster_token_cache: dict = {}
|
||||
_sd_poster_error_cache: dict = {}
|
||||
# Short process-local cooldown for transient poster errors (auth/network).
|
||||
# Image download limits are persisted on the EPG source (shared across workers).
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'poster':
|
||||
|
|
@ -506,98 +161,6 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
||||
def poster(self, request, pk=None):
|
||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
program = self.get_object()
|
||||
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
|
||||
if not poster_sd_url:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
source = program.epg.epg_source if program.epg else None
|
||||
if not source or source.source_type != 'schedules_direct':
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
|
||||
if error_cache and time.time() < error_cache['until']:
|
||||
return Response(
|
||||
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
cached = ProgramViewSet._sd_poster_token_cache.get(source.id)
|
||||
token = cached['token'] if cached and time.time() < cached['expires'] else None
|
||||
|
||||
if not token:
|
||||
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_resp = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': source.username, 'password': sha1_password},
|
||||
headers=dispatcharr_http_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
auth_data = auth_resp.json()
|
||||
token = auth_data.get('token')
|
||||
if not token:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': auth_data.get('message', 'Authentication failed'),
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
token_expires = auth_data.get('tokenExpires', time.time() + 86400)
|
||||
ProgramViewSet._sd_poster_token_cache[source.id] = {
|
||||
'token': token,
|
||||
'expires': token_expires,
|
||||
}
|
||||
except http_requests.exceptions.RequestException:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': 'Network error reaching Schedules Direct',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
try:
|
||||
img_resp = http_requests.get(
|
||||
poster_sd_url,
|
||||
headers=dispatcharr_http_headers(token=token, content_type=None),
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if img_resp.status_code in (401, 403):
|
||||
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': f'SD returned {img_resp.status_code}',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
if img_resp.status_code == 400:
|
||||
try:
|
||||
err_code = img_resp.json().get('code')
|
||||
except Exception:
|
||||
err_code = None
|
||||
if err_code == 5002:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': 'Daily image download limit reached (SD error 5002)',
|
||||
}
|
||||
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
if img_resp.status_code != 200:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
from django.http import HttpResponse
|
||||
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
||||
response = HttpResponse(img_resp.content, content_type=content_type)
|
||||
response['Cache-Control'] = 'public, max-age=86400'
|
||||
return response
|
||||
|
||||
except http_requests.exceptions.RequestException:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG programs.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
|
|
|||
571
apps/epg/sd_api.py
Normal file
571
apps/epg/sd_api.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""
|
||||
Schedules Direct HTTP API helpers and ViewSet mixins.
|
||||
|
||||
Keeps lineup management and poster proxy out of the general EPG view modules.
|
||||
Protocol/auth helpers live in ``sd_utils``; the refresh pipeline in ``sd_tasks``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests as http_requests
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.epg.sd_utils import (
|
||||
SD_AUTH_CREDENTIAL_LOCKOUT_CODES,
|
||||
SD_BASE_URL,
|
||||
SD_CODE_IMAGE_NOT_FOUND,
|
||||
SD_IMAGE_LIMIT_CODES,
|
||||
sd_auth_lockout_active,
|
||||
sd_authorized_request,
|
||||
sd_handle_2055,
|
||||
sd_image_limit_active,
|
||||
sd_mark_icon_missing,
|
||||
sd_next_midnight_utc,
|
||||
sd_obtain_token,
|
||||
sd_parse_response_payload,
|
||||
sd_save_image_limit_lockout,
|
||||
)
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulesDirectSourceMixin:
|
||||
"""Lineup management actions and helpers for Schedules Direct EPG sources."""
|
||||
|
||||
def _sd_authenticate(self, source):
|
||||
"""
|
||||
Authenticate with Schedules Direct using stored credentials.
|
||||
Returns (token, None) on success or (None, Response) on failure.
|
||||
"""
|
||||
result = sd_obtain_token(source, timeout=15)
|
||||
if result.ok:
|
||||
return result.token, None
|
||||
|
||||
if result.debug_rejected:
|
||||
return None, Response(
|
||||
{"error": result.message},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if result.message == 'Username and password are required.':
|
||||
http_status = status.HTTP_400_BAD_REQUEST
|
||||
elif result.code in SD_AUTH_CREDENTIAL_LOCKOUT_CODES:
|
||||
http_status = status.HTTP_401_UNAUTHORIZED
|
||||
elif result.lockout or result.soft or result.code:
|
||||
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
else:
|
||||
http_status = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
return None, Response({"error": result.message}, status=http_status)
|
||||
|
||||
def _get_sd_reset_at(self, source):
|
||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||
return reset_at_str
|
||||
|
||||
def _get_sd_changes_remaining(self, source):
|
||||
"""
|
||||
Retrieve stored changesRemaining from EPGSource model field.
|
||||
If a reset timestamp exists and has passed (midnight UTC), clears the
|
||||
lockout automatically so the user can make adds again.
|
||||
"""
|
||||
cp = source.custom_properties or {}
|
||||
changes_remaining = cp.get('sd_changes_remaining')
|
||||
reset_at_str = cp.get('sd_changes_reset_at')
|
||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||
|
||||
# If we have a reset timestamp and it has passed, clear the lockout
|
||||
if changes_remaining == 0 and reset_at:
|
||||
if timezone.now() >= reset_at:
|
||||
cp = source.custom_properties or {}
|
||||
cp.pop('sd_changes_remaining', None)
|
||||
cp.pop('sd_changes_reset_at', None)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
return None
|
||||
|
||||
return changes_remaining
|
||||
|
||||
def _save_sd_changes_remaining(self, source, changes_remaining):
|
||||
"""
|
||||
Persist changesRemaining on the EPG source.
|
||||
|
||||
When remaining hits 0, also store sd_changes_reset_at (next midnight UTC)
|
||||
so the lockout can clear automatically. A positive remaining clears any
|
||||
prior reset timestamp.
|
||||
"""
|
||||
cp = dict(source.custom_properties or {})
|
||||
cp['sd_changes_remaining'] = changes_remaining
|
||||
if changes_remaining == 0:
|
||||
cp['sd_changes_reset_at'] = sd_next_midnight_utc().isoformat()
|
||||
else:
|
||||
cp.pop('sd_changes_reset_at', None)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
def _save_sd_lockout(self, source):
|
||||
"""
|
||||
Persist a hard lockout when SD returns 4100 MAX_LINEUP_CHANGES_REACHED.
|
||||
|
||||
SD lineup change counters reset at 00:00Z (midnight UTC). Lockout clears
|
||||
automatically when that reset time passes.
|
||||
"""
|
||||
self._save_sd_changes_remaining(source, 0)
|
||||
reset_at = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||
logger.warning(
|
||||
f"SD source {source.id}: daily lineup change limit reached (4100). "
|
||||
f"Lockout set until {reset_at}."
|
||||
)
|
||||
|
||||
def _fetch_sd_countries(self):
|
||||
"""Fetch the SD country list (token not required; User-Agent is)."""
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/available/countries",
|
||||
headers=dispatcharr_http_headers(content_type=None),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Failed to fetch SD countries: {e}")
|
||||
return None
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
||||
def sd_lineups(self, request, pk=None):
|
||||
"""
|
||||
GET — list lineups currently on the SD account
|
||||
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
"""
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
if request.method == "GET":
|
||||
countries = self._fetch_sd_countries()
|
||||
try:
|
||||
resp, token = sd_authorized_request(
|
||||
'GET',
|
||||
f"{SD_BASE_URL}/lineups",
|
||||
source=source,
|
||||
token=token,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 4102:
|
||||
return Response({
|
||||
"lineups": [],
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
||||
"countries": countries,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
||||
return Response({
|
||||
"lineups": lineups,
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"countries": countries,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to fetch lineups: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "POST":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Honor a known daily change lockout without calling SD again.
|
||||
if self._get_sd_changes_remaining(source) == 0:
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": (
|
||||
"You have reached your daily Schedules Direct lineup "
|
||||
"change limit (6 add/delete operations per 24 hours). "
|
||||
"Resets at midnight UTC."
|
||||
),
|
||||
"changes_remaining": 0,
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
try:
|
||||
resp, token = sd_authorized_request(
|
||||
'PUT',
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
source=source,
|
||||
token=token,
|
||||
timeout=15,
|
||||
)
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
|
||||
if resp.status_code == 400 or resp.status_code == 403:
|
||||
if sd_code == 4100:
|
||||
self._save_sd_lockout(source)
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": (
|
||||
"You have reached your daily Schedules Direct lineup "
|
||||
"change limit (6 add/delete operations per 24 hours). "
|
||||
"Resets at midnight UTC."
|
||||
),
|
||||
"changes_remaining": 0,
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 4101:
|
||||
return Response({
|
||||
"error": "max_lineups_reached",
|
||||
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 2100:
|
||||
return Response({
|
||||
"error": "duplicate_lineup",
|
||||
"message": "This lineup is already on your Schedules Direct account.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response({
|
||||
"error": sd_data.get('message', 'Failed to add lineup.'),
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
# Persist changesRemaining to custom_properties
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
|
||||
logger.info(
|
||||
f"SD lineup added for source {source.id}: {lineup_id}. "
|
||||
f"changesRemaining: {changes_remaining}"
|
||||
)
|
||||
|
||||
# Re-fetch stations so the new lineup's stations are available for matching
|
||||
from apps.epg.tasks import fetch_schedules_direct_stations
|
||||
fetch_schedules_direct_stations.delay(source.id)
|
||||
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": changes_remaining,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to add lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "DELETE":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if self._get_sd_changes_remaining(source) == 0:
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": (
|
||||
"You have reached your daily Schedules Direct lineup "
|
||||
"change limit (6 add/delete operations per 24 hours). "
|
||||
"Resets at midnight UTC."
|
||||
),
|
||||
"changes_remaining": 0,
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
try:
|
||||
resp, token = sd_authorized_request(
|
||||
'DELETE',
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
source=source,
|
||||
token=token,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 2103:
|
||||
return Response({
|
||||
"response": "OK",
|
||||
"code": 0,
|
||||
"message": "Lineup not found on account — already removed.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
if sd_code == 4100:
|
||||
self._save_sd_lockout(source)
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": (
|
||||
"You have reached your daily Schedules Direct lineup "
|
||||
"change limit (6 add/delete operations per 24 hours). "
|
||||
"Resets at midnight UTC."
|
||||
),
|
||||
"changes_remaining": 0,
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
resp.raise_for_status()
|
||||
sd_data = resp.json()
|
||||
# SD returns changesRemaining on deletes — persist it
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to remove lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
||||
def sd_lineups_search(self, request, pk=None):
|
||||
"""
|
||||
Search available headends/lineups by country and postal code.
|
||||
Body: {"country": "USA", "postalcode": "07030"}
|
||||
Returns a flat list of lineups across all matching headends.
|
||||
"""
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
country = request.data.get('country', '').strip()
|
||||
postalcode = request.data.get('postalcode', '').strip()
|
||||
if not country or not postalcode:
|
||||
return Response(
|
||||
{"error": "country and postalcode are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
resp, token = sd_authorized_request(
|
||||
'GET',
|
||||
f"{SD_BASE_URL}/headends",
|
||||
source=source,
|
||||
token=token,
|
||||
timeout=15,
|
||||
params={'country': country, 'postalcode': postalcode},
|
||||
)
|
||||
try:
|
||||
headends = resp.json()
|
||||
except ValueError:
|
||||
headends = None
|
||||
if isinstance(headends, dict) and sd_handle_2055(source, headends):
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
"Schedules Direct rejected the debug routing header "
|
||||
"(code 2055). Extra Schedules Direct Debugging has "
|
||||
"been turned off."
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
if not isinstance(headends, list):
|
||||
headends = []
|
||||
lineups = []
|
||||
for headend in headends:
|
||||
for lineup in headend.get('lineups', []):
|
||||
lineups.append({
|
||||
'lineup': lineup.get('lineup'),
|
||||
'name': lineup.get('name'),
|
||||
'transport': headend.get('transport'),
|
||||
'location': headend.get('location'),
|
||||
'headend': headend.get('headend'),
|
||||
})
|
||||
return Response({"lineups": lineups})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to search headends: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
|
||||
|
||||
class SchedulesDirectPosterMixin:
|
||||
"""Program poster proxy for Schedules Direct artwork URIs."""
|
||||
|
||||
_sd_poster_error_cache: dict = {}
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
||||
def poster(self, request, pk=None):
|
||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||
program = self.get_object()
|
||||
cp = program.custom_properties or {}
|
||||
if cp.get('sd_icon_missing'):
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
poster_sd_url = cp.get('sd_icon')
|
||||
if not poster_sd_url:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
source = program.epg.epg_source if program.epg else None
|
||||
if not source or source.source_type != 'schedules_direct':
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Persisted lockout (shared across workers) until next midnight UTC.
|
||||
limit_active, limit_reason = sd_image_limit_active(source)
|
||||
if limit_active:
|
||||
self._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': limit_reason,
|
||||
}
|
||||
return Response(
|
||||
{'error': f"SD temporarily unavailable: {limit_reason}"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
auth_lockout_active, auth_lockout_reason, _lockout_code = (
|
||||
sd_auth_lockout_active(source)
|
||||
)
|
||||
if auth_lockout_active:
|
||||
# Rely on the persisted lockout only. Do not seed the process-local
|
||||
# poster error cache here: that cache outlives credential changes and
|
||||
# cooldown expiry and would keep returning 503 afterward.
|
||||
return Response(
|
||||
{'error': auth_lockout_reason},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
error_cache = self._sd_poster_error_cache.get(source.id)
|
||||
if error_cache and time.time() < error_cache['until']:
|
||||
return Response(
|
||||
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
auth = sd_obtain_token(source, timeout=10)
|
||||
if auth.debug_rejected:
|
||||
return Response(
|
||||
{'error': auth.message},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not auth.ok:
|
||||
# Lockout codes are persisted by sd_obtain_token. Other failures
|
||||
# use a short process-local cache so workers do not hammer /token.
|
||||
if not auth.lockout:
|
||||
self._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + (3600 if auth.code else 300),
|
||||
'reason': auth.message or 'Authentication failed',
|
||||
}
|
||||
return Response(
|
||||
{'error': auth.message},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
token = auth.token
|
||||
self._sd_poster_error_cache.pop(source.id, None)
|
||||
|
||||
try:
|
||||
img_resp, token = sd_authorized_request(
|
||||
'GET',
|
||||
poster_sd_url,
|
||||
source=source,
|
||||
token=token,
|
||||
timeout=15,
|
||||
content_type=None,
|
||||
allow_redirects=True,
|
||||
)
|
||||
err_code, err_data = sd_parse_response_payload(img_resp)
|
||||
if err_data is not None and sd_handle_2055(source, err_data):
|
||||
return Response(
|
||||
{
|
||||
'error': (
|
||||
'Schedules Direct rejected the debug routing header '
|
||||
'(code 2055). Extra Schedules Direct Debugging has '
|
||||
'been turned off.'
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# SD documents 5002/5003 as HTTP 200 + JSON. Also accept 4xx.
|
||||
if err_code in SD_IMAGE_LIMIT_CODES:
|
||||
sd_save_image_limit_lockout(source, err_code)
|
||||
self._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': (
|
||||
f'Daily image download limit reached (SD error {err_code})'
|
||||
),
|
||||
}
|
||||
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
# Only blacklist on explicit IMAGE_NOT_FOUND (5000). Bare HTTP 404 can
|
||||
# be a transient CDN/S3 miss for ephemeral URIs and must not permanently
|
||||
# clear sd_icon (SD docs: code 5000 + HTTP 404).
|
||||
if err_code == SD_CODE_IMAGE_NOT_FOUND:
|
||||
sd_mark_icon_missing(program)
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if img_resp.status_code == 404:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if img_resp.status_code in (401, 403):
|
||||
# Clear already happened inside sd_authorized_request; seed a short
|
||||
# process cache only when the fresh-token retry still failed.
|
||||
self._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': f'SD returned {img_resp.status_code}',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
# JSON error body (even on HTTP 200) is never a valid image.
|
||||
if err_data is not None and err_code not in (None, 0):
|
||||
logger.warning(
|
||||
"SD poster proxy: unexpected JSON error code %s for program %s",
|
||||
err_code,
|
||||
program.id,
|
||||
)
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
if img_resp.status_code != 200:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
||||
if 'json' in (content_type or '').lower():
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
response = HttpResponse(img_resp.content, content_type=content_type)
|
||||
response['Cache-Control'] = 'public, max-age=86400'
|
||||
return response
|
||||
|
||||
except http_requests.exceptions.RequestException:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
1840
apps/epg/sd_tasks.py
Normal file
1840
apps/epg/sd_tasks.py
Normal file
File diff suppressed because it is too large
Load diff
771
apps/epg/sd_utils.py
Normal file
771
apps/epg/sd_utils.py
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
"""
|
||||
Schedules Direct API helpers: headers, error codes, and rate-limit lockouts.
|
||||
|
||||
See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta, timezone as dt_timezone
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from core.utils import dispatcharr_http_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
|
||||
|
||||
# SD JSON error codes we must honor to avoid account blocks.
|
||||
SD_CODE_INVALID_DEBUG = 2055
|
||||
SD_CODE_SERVICE_OFFLINE = 3000
|
||||
SD_CODE_SERVICE_BUSY = 3001
|
||||
SD_CODE_ACCOUNT_EXPIRED = 4001
|
||||
SD_CODE_INVALID_PASSWORD_HASH = 4002
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD = 4003
|
||||
SD_CODE_ACCOUNT_LOCKED = 4004
|
||||
SD_CODE_JSON_DISABLED = 4005
|
||||
SD_CODE_APP_NOT_AUTHORIZED = 4007
|
||||
SD_CODE_ACCOUNT_INACTIVE = 4008
|
||||
SD_CODE_TOO_MANY_LOGINS = 4009
|
||||
SD_CODE_TOO_MANY_UNIQUE_IPS = 4010
|
||||
SD_CODE_IMAGE_NOT_FOUND = 5000
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS = 5002
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL = 5003
|
||||
|
||||
SD_IMAGE_LIMIT_CODES = frozenset({
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS,
|
||||
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL,
|
||||
})
|
||||
|
||||
# Soft /token failures: stop and retry later (idle), not a hard account error.
|
||||
SD_AUTH_SOFT_CODES = frozenset({
|
||||
SD_CODE_SERVICE_OFFLINE,
|
||||
SD_CODE_SERVICE_BUSY,
|
||||
})
|
||||
|
||||
# Wrong username/password (or hash): clear early when credentials change.
|
||||
SD_AUTH_CREDENTIAL_LOCKOUT_CODES = frozenset({
|
||||
SD_CODE_INVALID_PASSWORD_HASH,
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||
})
|
||||
|
||||
# All /token codes we must not hammer. Includes soft codes (shorter cooldown).
|
||||
SD_AUTH_LOCKOUT_CODES = frozenset({
|
||||
SD_CODE_SERVICE_OFFLINE,
|
||||
SD_CODE_SERVICE_BUSY,
|
||||
SD_CODE_ACCOUNT_EXPIRED,
|
||||
SD_CODE_INVALID_PASSWORD_HASH,
|
||||
SD_CODE_INVALID_USER_OR_PASSWORD,
|
||||
SD_CODE_ACCOUNT_LOCKED,
|
||||
SD_CODE_JSON_DISABLED,
|
||||
SD_CODE_APP_NOT_AUTHORIZED,
|
||||
SD_CODE_ACCOUNT_INACTIVE,
|
||||
SD_CODE_TOO_MANY_LOGINS,
|
||||
SD_CODE_TOO_MANY_UNIQUE_IPS,
|
||||
})
|
||||
|
||||
SD_AUTH_LOCKOUT_SECONDS = 24 * 3600
|
||||
SD_AUTH_LOCKOUT_SECONDS_SOFT = 3600
|
||||
SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK = 15 * 60
|
||||
|
||||
|
||||
def sd_auth_lockout_seconds_for_code(code):
|
||||
"""Cooldown length for a /token error code."""
|
||||
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||
return SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK
|
||||
if code in SD_AUTH_SOFT_CODES:
|
||||
return SD_AUTH_LOCKOUT_SECONDS_SOFT
|
||||
return SD_AUTH_LOCKOUT_SECONDS
|
||||
|
||||
|
||||
def sd_auth_lockout_retry_message():
|
||||
"""Suffix explaining how to clear an auth lockout."""
|
||||
return (
|
||||
"Not retrying Schedules Direct authentication until the username or "
|
||||
"password is updated, or the cooldown expires."
|
||||
)
|
||||
|
||||
|
||||
# Shared across uWSGI workers via Django's Redis cache.
|
||||
_SD_TOKEN_CACHE_PREFIX = 'sd:token:'
|
||||
# Expire a bit early so we re-auth before SD rejects an almost-expired token.
|
||||
_SD_TOKEN_CACHE_SKEW_SECONDS = 60
|
||||
_SD_TOKEN_DEFAULT_TTL_SECONDS = 86400
|
||||
|
||||
|
||||
def sd_token_cache_key(source_id):
|
||||
return f'{_SD_TOKEN_CACHE_PREFIX}{source_id}'
|
||||
|
||||
|
||||
def sd_credential_fingerprint(username, password):
|
||||
"""
|
||||
Stable fingerprint of SD credentials for lockout and token-cache matching.
|
||||
|
||||
Used so a persisted auth lockout or cached token clears automatically when
|
||||
the user changes username or password.
|
||||
"""
|
||||
raw = f'{(username or "").strip()}\0{(password or "").strip()}'
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def sd_get_cached_token(source_id, username=None, password=None):
|
||||
"""
|
||||
Return a cached SD token string for this source, or None.
|
||||
|
||||
Requires ``username`` / ``password`` so a cached session is only reused when
|
||||
credentials still match (avoids using account A's token after switching to B).
|
||||
On Redis failure, returns None so the caller re-authenticates.
|
||||
"""
|
||||
if source_id is None:
|
||||
return None
|
||||
current_fp = sd_credential_fingerprint(username, password)
|
||||
try:
|
||||
payload = cache.get(sd_token_cache_key(source_id))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"SD token cache get failed for source %s (%s); re-authenticating",
|
||||
source_id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
token = payload.get('token')
|
||||
expires = payload.get('expires')
|
||||
if not token or not isinstance(expires, (int, float)):
|
||||
return None
|
||||
if payload.get('fp') != current_fp:
|
||||
sd_clear_cached_token(source_id)
|
||||
return None
|
||||
if time.time() >= float(expires) - _SD_TOKEN_CACHE_SKEW_SECONDS:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def sd_set_cached_token(source_id, token, expires=None, username=None, password=None):
|
||||
"""
|
||||
Cache an SD token for this source until near tokenExpires.
|
||||
|
||||
``expires`` is a UNIX epoch seconds value from SD's tokenExpires field.
|
||||
Stores a credential fingerprint so callers must still match to reuse it.
|
||||
"""
|
||||
if source_id is None or not token:
|
||||
return False
|
||||
now = time.time()
|
||||
if expires is None:
|
||||
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||
try:
|
||||
expires = float(expires)
|
||||
except (TypeError, ValueError):
|
||||
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||
ttl = int(expires - now - _SD_TOKEN_CACHE_SKEW_SECONDS)
|
||||
if ttl < 1:
|
||||
return False
|
||||
try:
|
||||
cache.set(
|
||||
sd_token_cache_key(source_id),
|
||||
{
|
||||
'token': token,
|
||||
'expires': expires,
|
||||
'fp': sd_credential_fingerprint(username, password),
|
||||
},
|
||||
timeout=ttl,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"SD token cache set failed for source %s (%s)",
|
||||
source_id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def sd_clear_cached_token(source_id):
|
||||
"""Drop a cached SD token (e.g. after 401/403 from an image request)."""
|
||||
if source_id is None:
|
||||
return False
|
||||
try:
|
||||
cache.delete(sd_token_cache_key(source_id))
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"SD token cache delete failed for source %s (%s)",
|
||||
source_id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def sd_auth_failure_message(code, sd_message=None):
|
||||
"""Human-readable message for a Schedules Direct /token error code."""
|
||||
if code == SD_CODE_SERVICE_OFFLINE:
|
||||
return (
|
||||
"Schedules Direct is offline for maintenance. "
|
||||
"Do not retry for at least 1 hour."
|
||||
)
|
||||
if code == SD_CODE_SERVICE_BUSY:
|
||||
return "Schedules Direct is busy. Stop requesting and retry later."
|
||||
if code == SD_CODE_ACCOUNT_EXPIRED:
|
||||
return (
|
||||
"Schedules Direct: account has expired. Please renew your "
|
||||
"subscription at schedulesdirect.org."
|
||||
)
|
||||
if code == SD_CODE_INVALID_PASSWORD_HASH:
|
||||
return (
|
||||
"Schedules Direct: invalid password hash. Check that credentials "
|
||||
"are stored correctly."
|
||||
)
|
||||
if code == SD_CODE_INVALID_USER_OR_PASSWORD:
|
||||
return "Schedules Direct: invalid username or password."
|
||||
if code == SD_CODE_ACCOUNT_LOCKED:
|
||||
return (
|
||||
"Schedules Direct: account locked due to too many failed login "
|
||||
"attempts. Try again in 15 minutes."
|
||||
)
|
||||
if code == SD_CODE_JSON_DISABLED:
|
||||
return (
|
||||
"Schedules Direct: JSON API access is disabled for this account. "
|
||||
"Contact Schedules Direct support."
|
||||
)
|
||||
if code == SD_CODE_APP_NOT_AUTHORIZED:
|
||||
return (
|
||||
"Schedules Direct: this application is not authorized. Please "
|
||||
"contact the Dispatcharr maintainers."
|
||||
)
|
||||
if code == SD_CODE_ACCOUNT_INACTIVE:
|
||||
return (
|
||||
"Schedules Direct: account is inactive. Please log in to "
|
||||
"schedulesdirect.org to reactivate."
|
||||
)
|
||||
if code == SD_CODE_TOO_MANY_LOGINS:
|
||||
return (
|
||||
"Schedules Direct: too many login attempts in 24 hours. Token is "
|
||||
"valid for 24 hours. Check for misconfiguration."
|
||||
)
|
||||
if code == SD_CODE_TOO_MANY_UNIQUE_IPS:
|
||||
return (
|
||||
"Schedules Direct: too many unique IP addresses in 24 hours. "
|
||||
"Avoid VPNs or multiple locations, or contact SD support to raise "
|
||||
"the limit."
|
||||
)
|
||||
if sd_message:
|
||||
return f"Schedules Direct authentication failed (code {code}): {sd_message}"
|
||||
return f"Schedules Direct authentication failed (code {code})."
|
||||
|
||||
|
||||
def sd_token_response_code(auth_data):
|
||||
"""Return integer SD ``code`` from a /token JSON body, or 0."""
|
||||
if not isinstance(auth_data, dict):
|
||||
return 0
|
||||
code = auth_data.get('code', 0)
|
||||
return code if isinstance(code, int) else 0
|
||||
|
||||
|
||||
def sd_auth_lockout_active(source, username=None, password=None):
|
||||
"""
|
||||
Return (active, reason, code) for a persisted auth lockout.
|
||||
|
||||
Cleared when username/password changed, or sd_auth_lockout_until has passed.
|
||||
"""
|
||||
if source is None:
|
||||
return False, None, None
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
if not cp.get('sd_auth_lockout'):
|
||||
return False, None, None
|
||||
|
||||
until_str = cp.get('sd_auth_lockout_until')
|
||||
until = parse_datetime(until_str) if until_str else None
|
||||
if until is None or timezone.now() >= until:
|
||||
sd_clear_auth_lockout(source)
|
||||
return False, None, None
|
||||
|
||||
if username is None:
|
||||
username = source.username
|
||||
if password is None:
|
||||
password = source.password
|
||||
|
||||
stored_fp = cp.get('sd_auth_lockout_credential_fp')
|
||||
current_fp = sd_credential_fingerprint(username, password)
|
||||
if not stored_fp or stored_fp != current_fp:
|
||||
sd_clear_auth_lockout(source)
|
||||
return False, None, None
|
||||
|
||||
code = cp.get('sd_auth_lockout_code')
|
||||
reason = cp.get('sd_auth_lockout_reason') or (
|
||||
'Schedules Direct authentication is temporarily blocked.'
|
||||
)
|
||||
return True, reason, code if isinstance(code, int) else None
|
||||
|
||||
|
||||
def sd_save_auth_lockout(source, code, username=None, password=None, reason=None):
|
||||
"""
|
||||
Persist an auth lockout so we stop calling /token for a cooldown period.
|
||||
|
||||
Duration depends on the code (15m for 4004, 1h for offline/busy, else 24h).
|
||||
Cleared early if username/password change.
|
||||
"""
|
||||
if source is None:
|
||||
return
|
||||
if code not in SD_AUTH_LOCKOUT_CODES:
|
||||
return
|
||||
|
||||
if username is None:
|
||||
username = source.username
|
||||
if password is None:
|
||||
password = source.password
|
||||
|
||||
if reason is None:
|
||||
reason = sd_auth_failure_message(code)
|
||||
|
||||
seconds = sd_auth_lockout_seconds_for_code(code)
|
||||
until = timezone.now() + timedelta(seconds=seconds)
|
||||
cp = dict(source.custom_properties or {})
|
||||
cp['sd_auth_lockout'] = True
|
||||
cp['sd_auth_lockout_code'] = code
|
||||
cp['sd_auth_lockout_reason'] = reason
|
||||
cp['sd_auth_lockout_until'] = until.isoformat()
|
||||
cp['sd_auth_lockout_credential_fp'] = sd_credential_fingerprint(
|
||||
username, password
|
||||
)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
sd_clear_cached_token(source.id)
|
||||
logger.warning(
|
||||
"SD source %s: auth lockout (code %s). Not calling /token until "
|
||||
"username or password changes, or after %s.",
|
||||
source.id,
|
||||
code,
|
||||
until.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def sd_clear_auth_lockout(source):
|
||||
"""Clear a persisted auth lockout (success, credentials changed, or expired)."""
|
||||
if source is None:
|
||||
return
|
||||
cp = dict(source.custom_properties or {})
|
||||
changed = False
|
||||
for key in (
|
||||
'sd_auth_lockout',
|
||||
'sd_auth_lockout_code',
|
||||
'sd_auth_lockout_reason',
|
||||
'sd_auth_lockout_until',
|
||||
'sd_auth_lockout_credential_fp',
|
||||
):
|
||||
if key in cp:
|
||||
cp.pop(key, None)
|
||||
changed = True
|
||||
if changed:
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SDTokenAuthResult:
|
||||
"""Outcome of POST /token (or a short-circuit from a persisted lockout)."""
|
||||
|
||||
ok: bool
|
||||
token: str | None = None
|
||||
token_expires: float | None = None
|
||||
code: int | None = None
|
||||
message: str = ''
|
||||
soft: bool = False
|
||||
debug_rejected: bool = False
|
||||
lockout: bool = False
|
||||
|
||||
|
||||
_DEBUG_REJECTED_MESSAGE = (
|
||||
"Schedules Direct rejected the debug routing header (code 2055). "
|
||||
"Extra Schedules Direct Debugging has been turned off. Retry without it "
|
||||
"unless Schedules Direct support asked you to enable it."
|
||||
)
|
||||
|
||||
|
||||
def sd_obtain_token(source, username=None, password=None, *, timeout=30):
|
||||
"""
|
||||
Return a Schedules Direct session token, reusing a cached one when valid.
|
||||
|
||||
Shared by refresh, lineup/form auth, and the poster proxy. Checks Redis for
|
||||
an unexpired token bound to the current credentials before POSTing /token.
|
||||
Honors persisted lockouts, reads JSON ``code`` before HTTP status, and
|
||||
persists cooldowns for codes that must not be retried until cleared.
|
||||
"""
|
||||
if source is None:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Schedules Direct source is required.',
|
||||
)
|
||||
|
||||
if username is None:
|
||||
username = (source.username or '').strip()
|
||||
else:
|
||||
username = (username or '').strip()
|
||||
if password is None:
|
||||
password = (source.password or '').strip()
|
||||
else:
|
||||
password = (password or '').strip()
|
||||
|
||||
if not username or not password:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Username and password are required.',
|
||||
)
|
||||
|
||||
active, reason, lockout_code = sd_auth_lockout_active(
|
||||
source, username, password
|
||||
)
|
||||
if active:
|
||||
msg = f"{reason} {sd_auth_lockout_retry_message()}"
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=lockout_code,
|
||||
message=msg,
|
||||
soft=lockout_code in SD_AUTH_SOFT_CODES if lockout_code else False,
|
||||
lockout=True,
|
||||
)
|
||||
|
||||
cached = sd_get_cached_token(source.id, username=username, password=password)
|
||||
if cached:
|
||||
return SDTokenAuthResult(
|
||||
ok=True,
|
||||
token=cached,
|
||||
code=0,
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers=sd_headers_for_source(source),
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||
)
|
||||
|
||||
try:
|
||||
auth_data = response.json()
|
||||
except ValueError:
|
||||
auth_data = {}
|
||||
|
||||
if sd_handle_2055(source, auth_data):
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=SD_CODE_INVALID_DEBUG,
|
||||
message=_DEBUG_REJECTED_MESSAGE,
|
||||
debug_rejected=True,
|
||||
)
|
||||
|
||||
# Honor JSON ``code`` before raise_for_status. SD error semantics are in
|
||||
# the body; HTTP status alone is not reliable for auth failures.
|
||||
auth_code = sd_token_response_code(auth_data)
|
||||
if auth_code != 0:
|
||||
msg = sd_auth_failure_message(
|
||||
auth_code, auth_data.get('message', 'Unknown error')
|
||||
)
|
||||
soft = auth_code in SD_AUTH_SOFT_CODES
|
||||
locked = False
|
||||
if auth_code in SD_AUTH_LOCKOUT_CODES:
|
||||
sd_save_auth_lockout(
|
||||
source, auth_code, username, password, reason=msg
|
||||
)
|
||||
msg = f"{msg} {sd_auth_lockout_retry_message()}"
|
||||
locked = True
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
code=auth_code,
|
||||
message=msg,
|
||||
soft=soft,
|
||||
lockout=locked,
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message=f'Network error authenticating with Schedules Direct: {exc}',
|
||||
)
|
||||
|
||||
token = auth_data.get('token') if isinstance(auth_data, dict) else None
|
||||
if not token:
|
||||
return SDTokenAuthResult(
|
||||
ok=False,
|
||||
message='Schedules Direct returned no token.',
|
||||
)
|
||||
|
||||
sd_clear_auth_lockout(source)
|
||||
expires = None
|
||||
if isinstance(auth_data, dict):
|
||||
expires = auth_data.get('tokenExpires')
|
||||
if not isinstance(expires, (int, float)):
|
||||
expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS
|
||||
expires = float(expires)
|
||||
sd_set_cached_token(
|
||||
source.id, token, expires, username=username, password=password
|
||||
)
|
||||
return SDTokenAuthResult(
|
||||
ok=True,
|
||||
token=token,
|
||||
token_expires=expires,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
def sd_authorized_request(
|
||||
method,
|
||||
url,
|
||||
*,
|
||||
source,
|
||||
token,
|
||||
username=None,
|
||||
password=None,
|
||||
timeout=30,
|
||||
content_type='application/json',
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Perform an authenticated Schedules Direct HTTP request.
|
||||
|
||||
On HTTP 401/403 (SD documents TOKEN_EXPIRED as 403 + code 4006), clears the
|
||||
Redis token cache, obtains a fresh token, and retries the request once.
|
||||
|
||||
Returns ``(response, token)`` where ``token`` may have been refreshed.
|
||||
"""
|
||||
method_upper = (method or 'GET').upper()
|
||||
http_fn = {
|
||||
'GET': requests.get,
|
||||
'POST': requests.post,
|
||||
'PUT': requests.put,
|
||||
'DELETE': requests.delete,
|
||||
'HEAD': requests.head,
|
||||
'PATCH': requests.patch,
|
||||
}.get(method_upper)
|
||||
|
||||
def _once(current_token):
|
||||
headers = sd_headers_for_source(
|
||||
source,
|
||||
token=current_token,
|
||||
content_type=content_type,
|
||||
)
|
||||
if http_fn is not None:
|
||||
return http_fn(url, headers=headers, timeout=timeout, **kwargs)
|
||||
return requests.request(
|
||||
method_upper, url, headers=headers, timeout=timeout, **kwargs
|
||||
)
|
||||
|
||||
response = _once(token)
|
||||
if response.status_code not in (401, 403):
|
||||
return response, token
|
||||
|
||||
logger.warning(
|
||||
"SD source %s: %s %s returned %s; clearing cached token and retrying once",
|
||||
getattr(source, 'id', None),
|
||||
method_upper,
|
||||
url,
|
||||
response.status_code,
|
||||
)
|
||||
sd_clear_cached_token(getattr(source, 'id', None))
|
||||
auth_timeout = timeout if isinstance(timeout, (int, float)) else 30
|
||||
auth = sd_obtain_token(
|
||||
source,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=min(int(auth_timeout), 30) if auth_timeout else 30,
|
||||
)
|
||||
if not auth.ok or not auth.token:
|
||||
return response, token
|
||||
|
||||
retry_response = _once(auth.token)
|
||||
return retry_response, auth.token
|
||||
|
||||
|
||||
def sd_next_midnight_utc():
|
||||
"""Return the next Schedules Direct counter reset (00:00Z)."""
|
||||
now = timezone.now()
|
||||
return (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0,
|
||||
tzinfo=dt_timezone.utc,
|
||||
)
|
||||
|
||||
|
||||
def sd_headers_for_source(source, *, token=None, content_type='application/json'):
|
||||
"""
|
||||
Build outbound headers for Schedules Direct requests for this source.
|
||||
|
||||
When Extra Schedules Direct Debugging is enabled, adds RouteTo: debug so the
|
||||
SD load balancer can steer traffic to their debug server (support-coordinated).
|
||||
"""
|
||||
cp = source.custom_properties or {} if source is not None else {}
|
||||
route_to = 'debug' if cp.get('sd_extra_debugging') else None
|
||||
return dispatcharr_http_headers(
|
||||
token=token,
|
||||
content_type=content_type,
|
||||
route_to=route_to,
|
||||
)
|
||||
|
||||
|
||||
def sd_disable_extra_debugging(source):
|
||||
"""Clear the user-facing debug toggle after SD returns code 2055."""
|
||||
if source is None:
|
||||
return False
|
||||
cp = dict(source.custom_properties or {})
|
||||
if not cp.get('sd_extra_debugging'):
|
||||
return False
|
||||
cp['sd_extra_debugging'] = False
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
logger.warning(
|
||||
"SD source %s: received code 2055 (unexpected debug connection). "
|
||||
"Disabled Extra Schedules Direct Debugging.",
|
||||
source.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def sd_handle_2055(source, data):
|
||||
"""
|
||||
If data is an SD JSON error with code 2055, disable debug routing.
|
||||
|
||||
Returns True when 2055 was handled.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
if data.get('code') != SD_CODE_INVALID_DEBUG:
|
||||
return False
|
||||
sd_disable_extra_debugging(source)
|
||||
return True
|
||||
|
||||
|
||||
def sd_parse_response_payload(response):
|
||||
"""Return (code, data_dict_or_None) for an SD HTTP response body."""
|
||||
if response is None:
|
||||
return None, None
|
||||
|
||||
content_type = (response.headers.get('Content-Type') or '').lower()
|
||||
body = response.content or b''
|
||||
looks_json = (
|
||||
'json' in content_type
|
||||
or body.lstrip()[:1] in (b'{', b'[')
|
||||
)
|
||||
if not looks_json:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None, None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None, None
|
||||
code = data.get('code')
|
||||
return (code if isinstance(code, int) else None), data
|
||||
|
||||
|
||||
def sd_image_limit_active(source):
|
||||
"""
|
||||
Return (active: bool, reason: str|None) for a persisted image download lockout.
|
||||
|
||||
Clears the lockout automatically once the next midnight UTC has passed.
|
||||
"""
|
||||
if source is None:
|
||||
return False, None
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
if not cp.get('sd_image_limit_hit'):
|
||||
return False, None
|
||||
|
||||
reset_at_str = cp.get('sd_image_limit_reset_at')
|
||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||
if reset_at and timezone.now() >= reset_at:
|
||||
sd_clear_image_limit_lockout(source)
|
||||
return False, None
|
||||
|
||||
reason = cp.get('sd_image_limit_reason') or (
|
||||
'Daily image download limit reached'
|
||||
)
|
||||
return True, reason
|
||||
|
||||
|
||||
def sd_save_image_limit_lockout(source, code):
|
||||
"""
|
||||
Persist a source-wide image download lockout until next 00:00Z.
|
||||
|
||||
Used for SD codes 5002 (subscriber) and 5003 (trial).
|
||||
"""
|
||||
if source is None:
|
||||
return
|
||||
|
||||
reset_at = sd_next_midnight_utc()
|
||||
reason = (
|
||||
f'Daily image download limit reached (SD error {code})'
|
||||
if code
|
||||
else 'Daily image download limit reached'
|
||||
)
|
||||
cp = dict(source.custom_properties or {})
|
||||
cp['sd_image_limit_hit'] = True
|
||||
cp['sd_image_limit_reset_at'] = reset_at.isoformat()
|
||||
cp['sd_image_limit_reason'] = reason
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
logger.warning(
|
||||
"SD source %s: image download limit (code %s). Lockout until %s.",
|
||||
source.id,
|
||||
code,
|
||||
reset_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def sd_clear_image_limit_lockout(source):
|
||||
"""Clear a persisted image download lockout after midnight UTC."""
|
||||
if source is None:
|
||||
return
|
||||
cp = dict(source.custom_properties or {})
|
||||
changed = False
|
||||
for key in (
|
||||
'sd_image_limit_hit',
|
||||
'sd_image_limit_reset_at',
|
||||
'sd_image_limit_reason',
|
||||
):
|
||||
if key in cp:
|
||||
cp.pop(key, None)
|
||||
changed = True
|
||||
if changed:
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
|
||||
def sd_mark_icon_missing(program):
|
||||
"""
|
||||
Clear a bad image URI so we never re-request it (SD code 5000).
|
||||
|
||||
Continues requesting the same missing URI can accumulate toward code 5004
|
||||
and get the account blocked.
|
||||
"""
|
||||
if program is None:
|
||||
return
|
||||
cp = dict(program.custom_properties or {})
|
||||
if 'sd_icon' not in cp and cp.get('sd_icon_missing'):
|
||||
return
|
||||
cp.pop('sd_icon', None)
|
||||
cp['sd_icon_missing'] = True
|
||||
program.custom_properties = cp
|
||||
program.save(update_fields=['custom_properties'])
|
||||
logger.info(
|
||||
"SD program %s: IMAGE_NOT_FOUND (5000). Cleared sd_icon to avoid retries.",
|
||||
program.id,
|
||||
)
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
from apps.epg.sd_utils import (
|
||||
sd_clear_cached_token,
|
||||
sd_credential_fingerprint,
|
||||
)
|
||||
from apps.epg.utils import sd_poster_proxy_path
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
from rest_framework import serializers
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
|
|
@ -67,11 +72,16 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
instance._cron_expression = cron_expr
|
||||
prior_fp = sd_credential_fingerprint(instance.username, instance.password)
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'password' and not value:
|
||||
continue
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
# Drop any Redis SD session tied to the previous username/password so
|
||||
# poster traffic cannot keep using another account's token.
|
||||
if prior_fp != sd_credential_fingerprint(instance.username, instance.password):
|
||||
sd_clear_cached_token(instance.id)
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
|
|
@ -169,9 +179,11 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
|||
data['icon'] = cp.get('icon')
|
||||
data['images'] = cp.get('images') or []
|
||||
|
||||
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth
|
||||
if cp.get('sd_icon'):
|
||||
poster_path = f"/api/epg/programs/{obj.id}/poster/"
|
||||
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth.
|
||||
# ``?v=`` tracks the sd_icon URI so nginx/browser caches bust when artwork changes.
|
||||
sd_icon = cp.get('sd_icon')
|
||||
if sd_icon:
|
||||
poster_path = sd_poster_proxy_path(obj.id, sd_icon)
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
|
||||
|
|
|
|||
1838
apps/epg/tasks.py
1838
apps/epg/tasks.py
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -484,6 +484,34 @@ class ProgramDataSerializerDescriptionFallbackTests(TestCase):
|
|||
self.assertEqual(data["episode"], 1)
|
||||
|
||||
|
||||
class SDPosterProxyPathTests(TestCase):
|
||||
"""Cache-bust query on SD poster proxy URLs."""
|
||||
|
||||
def test_stable_uri_stable_bust(self):
|
||||
from apps.epg.utils import sd_poster_cache_bust, sd_poster_proxy_path
|
||||
|
||||
uri = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
|
||||
self.assertEqual(sd_poster_cache_bust(uri), sd_poster_cache_bust(uri))
|
||||
path = sd_poster_proxy_path(42, uri)
|
||||
self.assertTrue(path.startswith('/api/epg/programs/42/poster/?v='))
|
||||
self.assertIn(sd_poster_cache_bust(uri), path)
|
||||
|
||||
def test_uri_change_changes_bust(self):
|
||||
from apps.epg.utils import sd_poster_cache_bust
|
||||
|
||||
a = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
|
||||
b = 'https://json.schedulesdirect.org/20141201/image/assets/b.jpg'
|
||||
self.assertNotEqual(sd_poster_cache_bust(a), sd_poster_cache_bust(b))
|
||||
|
||||
def test_empty_uri_has_no_query(self):
|
||||
from apps.epg.utils import sd_poster_proxy_path
|
||||
|
||||
self.assertEqual(
|
||||
sd_poster_proxy_path(7, None),
|
||||
'/api/epg/programs/7/poster/',
|
||||
)
|
||||
|
||||
|
||||
class ProgramDetailSerializerTests(TestCase):
|
||||
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
|
||||
|
||||
|
|
@ -670,3 +698,16 @@ class ProgramDetailSerializerTests(TestCase):
|
|||
self.assertEqual(slim["is_live"], detail["is_live"])
|
||||
self.assertEqual(slim["is_premiere"], detail["is_premiere"])
|
||||
self.assertEqual(slim["is_finale"], detail["is_finale"])
|
||||
|
||||
def test_poster_url_includes_cache_bust_from_sd_icon(self):
|
||||
from apps.epg.utils import sd_poster_proxy_path
|
||||
|
||||
uri = 'https://json.schedulesdirect.org/20141201/image/assets/p.jpg'
|
||||
program = self._create_program(custom_properties={"sd_icon": uri})
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertEqual(data["poster_url"], sd_poster_proxy_path(program.id, uri))
|
||||
|
||||
def test_poster_url_none_without_sd_icon(self):
|
||||
program = self._create_program(custom_properties={})
|
||||
data = ProgramDetailSerializer(program).data
|
||||
self.assertIsNone(data["poster_url"])
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"""
|
||||
Shared EPG utilities — season/episode extraction.
|
||||
Shared EPG utilities.
|
||||
|
||||
These live here (rather than in serializers.py or tasks.py) to avoid circular imports:
|
||||
serializers → tasks and channels/tasks → serializers both need these functions.
|
||||
Season/episode extraction, WebSocket progress updates, and SD poster proxy URL
|
||||
helpers live here so serializers, XMLTV output, and tasks can import without
|
||||
circular dependencies.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from core.utils import send_websocket_update
|
||||
|
||||
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
|
||||
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
|
||||
|
||||
|
|
@ -21,6 +26,34 @@ _DESC_SE_PATTERNS = [
|
|||
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
|
||||
]
|
||||
|
||||
_SD_POSTER_CACHE_BUST_LEN = 12
|
||||
|
||||
|
||||
def sd_poster_cache_bust(sd_icon_url):
|
||||
"""
|
||||
Short content hash of an SD poster URI for nginx cache busting.
|
||||
|
||||
Same URI keeps the same ``v`` (long-lived nginx cache). A new artwork URI
|
||||
after refresh gets a new ``v`` so clients do not keep stale bytes.
|
||||
"""
|
||||
if not sd_icon_url:
|
||||
return ''
|
||||
return hashlib.sha256(sd_icon_url.encode('utf-8')).hexdigest()[:_SD_POSTER_CACHE_BUST_LEN]
|
||||
|
||||
|
||||
def sd_poster_proxy_path(program_id, sd_icon_url):
|
||||
"""
|
||||
Relative proxy path for a program poster.
|
||||
|
||||
Includes ``?v=`` when ``sd_icon_url`` is set so nginx cache keys change with
|
||||
the upstream SD URI. The poster endpoint ignores ``v``; nginx keys on full URI.
|
||||
"""
|
||||
path = f'/api/epg/programs/{program_id}/poster/'
|
||||
bust = sd_poster_cache_bust(sd_icon_url)
|
||||
if not bust:
|
||||
return path
|
||||
return f'{path}?v={bust}'
|
||||
|
||||
|
||||
def extract_season_episode_from_description(desc):
|
||||
"""
|
||||
|
|
@ -59,3 +92,23 @@ def extract_season_episode(cp, description=None):
|
|||
if episode is None:
|
||||
episode = d_episode
|
||||
return season, episode
|
||||
|
||||
|
||||
def send_epg_update(source_id, action, progress, **kwargs):
|
||||
"""Send WebSocket update about EPG download/parsing progress."""
|
||||
data = {
|
||||
"progress": progress,
|
||||
"type": "epg_refresh",
|
||||
"source": source_id,
|
||||
"action": action,
|
||||
}
|
||||
data.update(kwargs)
|
||||
|
||||
# High-frequency program parsing needs more aggressive memory management
|
||||
collect_garbage = action == "parsing_programs" and progress % 10 == 0
|
||||
send_websocket_update('updates', 'update', data, collect_garbage=collect_garbage)
|
||||
|
||||
data = None
|
||||
|
||||
if action == "parsing_programs" and progress % 50 == 0:
|
||||
gc.collect()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from django.utils import timezone as django_timezone
|
|||
from apps.channels.models import Channel, ChannelProfile, Stream
|
||||
from apps.channels.utils import format_channel_number
|
||||
from apps.epg.models import ProgramData
|
||||
from apps.epg.utils import sd_poster_proxy_path
|
||||
from apps.output.streaming_chunk_cache import stream_cached_response
|
||||
from core.utils import build_absolute_uri_with_port, log_system_event
|
||||
|
||||
|
|
@ -1379,7 +1380,7 @@ def generate_epg(request, profile_name=None, user=None, *, xc_catchup_prev_days=
|
|||
chunk_size = _EPG_PROGRAM_DB_CHUNK_SIZE
|
||||
last_epg_id = 0
|
||||
last_id = 0
|
||||
_poster_url_base = build_absolute_uri_with_port(request, "/api/epg/programs/")
|
||||
_poster_site_origin = build_absolute_uri_with_port(request, '')
|
||||
|
||||
def flush_pending():
|
||||
nonlocal program_batch, pending
|
||||
|
|
@ -1592,7 +1593,13 @@ def generate_epg(request, profile_name=None, user=None, *, xc_catchup_prev_days=
|
|||
if "icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
|
||||
elif "sd_icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(_poster_url_base)}{prog["id"]}/poster/" />')
|
||||
poster_src = (
|
||||
f'{_poster_site_origin}'
|
||||
f'{sd_poster_proxy_path(prog["id"], custom_data["sd_icon"])}'
|
||||
)
|
||||
program_xml.append(
|
||||
f' <icon src="{html.escape(poster_src)}" />'
|
||||
)
|
||||
|
||||
# Add special flags as proper tags with enhanced handling
|
||||
if custom_data.get("previously_shown", False):
|
||||
|
|
|
|||
|
|
@ -283,6 +283,15 @@ class DispatcharrUserAgentTests(TestCase):
|
|||
{'User-Agent': 'Dispatcharr/1.2.3'},
|
||||
)
|
||||
|
||||
@patch('version.__version__', '1.2.3')
|
||||
def test_dispatcharr_http_headers_with_route_to(self):
|
||||
from core.utils import dispatcharr_http_headers
|
||||
headers = dispatcharr_http_headers(content_type=None, route_to='debug')
|
||||
self.assertEqual(headers, {
|
||||
'User-Agent': 'Dispatcharr/1.2.3',
|
||||
'RouteTo': 'debug',
|
||||
})
|
||||
|
||||
|
||||
class ProgrammeIndexRebuildTests(TestCase):
|
||||
def test_startup_rebuild_does_not_lock_out_queued_build_task(self):
|
||||
|
|
|
|||
|
|
@ -33,18 +33,22 @@ def dispatcharr_dvr_user_agent(recording_id):
|
|||
return f'Dispatcharr-DVR/recording-{recording_id}'
|
||||
|
||||
|
||||
def dispatcharr_http_headers(*, token=None, content_type='application/json'):
|
||||
def dispatcharr_http_headers(*, token=None, content_type='application/json', route_to=None):
|
||||
"""
|
||||
Build HTTP headers for outbound Dispatcharr requests.
|
||||
|
||||
content_type=None omits Content-Type (e.g. simple GET proxies).
|
||||
token is included when authenticating with Schedules Direct.
|
||||
route_to is an optional Schedules Direct load-balancer steer value
|
||||
(e.g. "debug"); only set when coordinated with Schedules Direct support.
|
||||
"""
|
||||
headers = {'User-Agent': dispatcharr_user_agent()}
|
||||
if content_type:
|
||||
headers['Content-Type'] = content_type
|
||||
if token:
|
||||
headers['token'] = token
|
||||
if route_to:
|
||||
headers['RouteTo'] = route_to
|
||||
return headers
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
#!/bin/bash
|
||||
|
||||
# TEMPORARY: one-time move of the nginx logo proxy cache to the new layout.
|
||||
# Removable once installs have had time to upgrade past the /data/cache/ paths.
|
||||
if [ -d /data/logo_cache ] && [ ! -e /data/cache/logos ]; then
|
||||
mkdir -p /data/cache
|
||||
if mv /data/logo_cache /data/cache/logos; then
|
||||
echo "Moved nginx logo cache from /data/logo_cache to /data/cache/logos"
|
||||
else
|
||||
echo "⚠️ Warning: could not move /data/logo_cache to /data/cache/logos"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Define directories that need to exist and be owned by PUID:PGID.
|
||||
# DATA_DIRS may reside on external mounts (NFS, SMB/CIFS, FUSE) where
|
||||
# mkdir and chown can fail. Failures are collected and reported as a
|
||||
|
|
@ -7,7 +18,9 @@
|
|||
DATA_DIRS=(
|
||||
"/data/backups"
|
||||
"/data/logos"
|
||||
"/data/logo_cache"
|
||||
"/data/cache"
|
||||
"/data/cache/logos"
|
||||
"/data/cache/sd_posters"
|
||||
"/data/recordings"
|
||||
"/data/uploads/m3us"
|
||||
"/data/uploads/epgs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
proxy_cache_path /data/logo_cache levels=1:2 keys_zone=logo_cache:10m
|
||||
# Channel/VOD logos and SD program posters share /data/cache/ with separate zones.
|
||||
proxy_cache_path /data/cache/logos levels=1:2 keys_zone=logo_cache:10m
|
||||
inactive=24h use_temp_path=off;
|
||||
|
||||
proxy_cache_path /data/cache/sd_posters levels=1:2 keys_zone=sd_poster_cache:10m
|
||||
inactive=14d use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen NGINX_PORT;
|
||||
listen [::]:NGINX_PORT;
|
||||
|
|
@ -58,11 +62,12 @@ server {
|
|||
proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow
|
||||
}
|
||||
|
||||
# SD program posters: 14d nginx cache. Clients pass ?v=<hash of sd_icon>
|
||||
location ~ ^/api/epg/programs/(?<prog_id>\d+)/poster/ {
|
||||
proxy_pass http://127.0.0.1:5656;
|
||||
proxy_cache logo_cache;
|
||||
proxy_cache sd_poster_cache;
|
||||
proxy_cache_key "$scheme$request_uri";
|
||||
proxy_cache_valid 200 24h;
|
||||
proxy_cache_valid 200 14d;
|
||||
proxy_cache_use_stale error timeout updating;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@ const SDSettings = ({ sourceId, customProperties }) => {
|
|||
const [posterStyle, setPosterStyle] = useState(
|
||||
resolvedCp.poster_style || 'sd_recommended'
|
||||
);
|
||||
const [extraDebugging, setExtraDebugging] = useState(
|
||||
!!resolvedCp.sd_extra_debugging
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Sync from store (preferred) or parent props when the form opens or settings save
|
||||
|
|
@ -178,6 +181,7 @@ const SDSettings = ({ sourceId, customProperties }) => {
|
|||
setLogoStyle(newCp.logo_style || 'dark');
|
||||
setFetchPosters(!!newCp.fetch_posters);
|
||||
setPosterStyle(newCp.poster_style || 'sd_recommended');
|
||||
setExtraDebugging(!!newCp.sd_extra_debugging);
|
||||
}, [storeCustomProps, customProperties]);
|
||||
|
||||
const saveSetting = async (key, value) => {
|
||||
|
|
@ -205,6 +209,11 @@ const SDSettings = ({ sourceId, customProperties }) => {
|
|||
saveSetting('poster_style', style);
|
||||
};
|
||||
|
||||
const handleExtraDebuggingToggle = (checked) => {
|
||||
setExtraDebugging(checked);
|
||||
saveSetting('sd_extra_debugging', checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
|
|
@ -260,17 +269,11 @@ const SDSettings = ({ sourceId, customProperties }) => {
|
|||
))}
|
||||
</Group>
|
||||
|
||||
<AutoApplyEpgLogosSwitch
|
||||
sourceId={sourceId}
|
||||
customProperties={customProperties}
|
||||
description="When enabled, matched channels are updated to use the SD logo on each refresh. When disabled, logos are still fetched into EPG data and can be applied manually via Set Logo from EPG."
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Switch
|
||||
label="Fetch Program Posters"
|
||||
description="WARNING: USES ADDITIONAL API REQUESTS. Poster artwork is fetched during EPG refresh; image bytes are cached by nginx on first view (24h). Initial fetch and viewing new programs consume API requests against your Schedules Direct rate limit."
|
||||
description="Stores poster links on refresh. Images download on first view (nginx caches ~14 days) and count toward your daily SD image limit."
|
||||
checked={fetchPosters}
|
||||
onChange={(e) => handlePosterToggle(e.currentTarget.checked)}
|
||||
disabled={saving}
|
||||
|
|
@ -290,6 +293,17 @@ const SDSettings = ({ sourceId, customProperties }) => {
|
|||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Switch
|
||||
label="Extra Schedules Direct Debugging"
|
||||
description="Only enable if Schedules Direct support asks you to. Turns off automatically if debug access is not allowed for your account."
|
||||
checked={extraDebugging}
|
||||
onChange={(e) => handleExtraDebuggingToggle(e.currentTarget.checked)}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -422,18 +436,27 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
setRemovingLineup(lineup.lineup);
|
||||
try {
|
||||
const result = await API.deleteSDLineup(sourceId, lineup.lineup);
|
||||
if (result && result.code === 0) {
|
||||
if (!result) return;
|
||||
|
||||
if (
|
||||
result.changes_remaining !== undefined &&
|
||||
result.changes_remaining !== null
|
||||
) {
|
||||
setChangesRemaining(result.changes_remaining);
|
||||
}
|
||||
|
||||
if (result.error === 'daily_limit_reached') {
|
||||
setChangesRemaining(0);
|
||||
await fetchActiveLineups();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.code === 0) {
|
||||
showNotification({
|
||||
title: 'Lineup removed',
|
||||
message: lineup.name,
|
||||
color: 'blue',
|
||||
});
|
||||
if (
|
||||
result.changes_remaining !== undefined &&
|
||||
result.changes_remaining !== null
|
||||
) {
|
||||
setChangesRemaining(result.changes_remaining);
|
||||
}
|
||||
await fetchActiveLineups();
|
||||
setSearchResults([]);
|
||||
}
|
||||
|
|
@ -495,6 +518,7 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
variant="subtle"
|
||||
leftSection={<Trash2 size={12} />}
|
||||
loading={removingLineup === lineup.lineup}
|
||||
disabled={changesRemaining === 0}
|
||||
onClick={() => handleRemove(lineup)}
|
||||
>
|
||||
Remove
|
||||
|
|
@ -527,8 +551,9 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
mb="xs"
|
||||
icon={<TriangleAlert size={14} />}
|
||||
>
|
||||
You have reached your daily Schedules Direct lineup addition limit. SD
|
||||
allows 6 adds per 24-hour period.{' '}
|
||||
You have reached your daily Schedules Direct lineup change limit. SD
|
||||
allows 6 add or delete operations per 24-hour period. Adds and removes
|
||||
are both blocked until the limit resets.{' '}
|
||||
{changesResetAt && (
|
||||
<span>
|
||||
Limit resets at{' '}
|
||||
|
|
@ -536,7 +561,7 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
</span>
|
||||
)}
|
||||
{!changesResetAt && (
|
||||
<span>Limit resets 24 hours after the first add of the day. </span>
|
||||
<span>Limit resets at midnight UTC. </span>
|
||||
)}
|
||||
<a href={SD_DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
Learn more
|
||||
|
|
@ -551,8 +576,9 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
mb="xs"
|
||||
icon={<TriangleAlert size={14} />}
|
||||
>
|
||||
You have <strong>1 lineup addition remaining</strong> today. Use it
|
||||
carefully — Schedules Direct limits adds to 6 per 24-hour period.{' '}
|
||||
You have <strong>1 lineup change remaining</strong> today. Use it
|
||||
carefully. Schedules Direct limits adds and deletes to 6 per 24-hour
|
||||
period.{' '}
|
||||
<a href={SD_DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
Learn more
|
||||
</a>
|
||||
|
|
@ -566,8 +592,8 @@ const SDLineupManager = ({ sourceId }) => {
|
|||
mb="xs"
|
||||
icon={<TriangleAlert size={14} />}
|
||||
>
|
||||
You have <strong>2 lineup additions remaining</strong> today.
|
||||
Schedules Direct limits adds to 6 per 24-hour period.{' '}
|
||||
You have <strong>2 lineup changes remaining</strong> today. Schedules
|
||||
Direct limits adds and deletes to 6 per 24-hour period.{' '}
|
||||
<a href={SD_DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
Learn more
|
||||
</a>
|
||||
|
|
@ -935,7 +961,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
key={form.key('priority')}
|
||||
/>
|
||||
|
||||
{sourceType === 'xmltv' && savedEpgId && (
|
||||
{savedEpgId && (
|
||||
<AutoApplyEpgLogosSwitch
|
||||
sourceId={savedEpgId}
|
||||
customProperties={sdCustomProps}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue