feat(epg): Add Extra Schedules Direct Debugging option and improve error handling

This commit introduces an Extra Schedules Direct Debugging toggle in the EPG source settings, allowing users to enable a `RouteTo: debug` header for troubleshooting with Schedules Direct support. The implementation includes automatic disabling of this option if Schedules Direct returns code 2055. Additionally, error handling for image download limits has been enhanced to prevent account blocking, with appropriate responses for various error codes. Tests have been added to ensure the correct behavior of these features.
This commit is contained in:
SergeantPanda 2026-07-20 21:38:34 +00:00
parent e94a1acc1c
commit ac38adcfce
8 changed files with 658 additions and 29 deletions

View file

@ -7,8 +7,13 @@ 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.
### 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.
- **Fresh AIO installs no longer crash during first-boot migrate when Redis is not up yet.** In AIO, `manage.py migrate` runs in the entrypoint before uWSGI starts Redis via `attach-daemon`. After CoreSettings group caching landed in 0.28.0, data migrations such as `m3u.0003` called live `CoreSettings.get_default_user_agent_id()` → Redis and failed with connection refused, leaving a partially migrated DB and a boot loop. Settings group cache reads/writes now fall back to Postgres on connectivity/timeout errors (with a throttled warning), skip cache fill on backend failure so a flapping Redis cannot re-poison entries after invalidate, and leave auth/protocol Redis errors loud. A regression test migrates a throwaway empty database with Redis unreachable. (Fixes #1459)
## [0.28.0] - 2026-07-19

View file

@ -133,7 +133,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
import hashlib
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
username = (source.username or '').strip()
password = (source.password or '').strip()
@ -148,11 +148,27 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
auth_response = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': username, 'password': sha1_password},
headers=dispatcharr_http_headers(),
headers=sd_headers_for_source(source),
timeout=15,
)
try:
auth_data = auth_response.json()
except ValueError:
auth_data = {}
if sd_handle_2055(source, auth_data):
return None, Response(
{
"error": (
"Schedules Direct rejected the debug routing header "
"(code 2055). Extra Schedules Direct Debugging has "
"been turned off. Retry without it unless SD support "
"asked you to enable it."
),
},
status=status.HTTP_400_BAD_REQUEST,
)
auth_response.raise_for_status()
token = auth_response.json().get('token')
token = auth_data.get('token')
if not token:
return None, Response(
{"error": "Authentication failed. Check your credentials."},
@ -259,7 +275,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -272,7 +288,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = dispatcharr_http_headers(token=token)
headers = sd_headers_for_source(source, token=token)
if request.method == "GET":
countries = self._fetch_sd_countries()
@ -421,7 +437,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -442,7 +458,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = dispatcharr_http_headers(token=token)
headers = sd_headers_for_source(source, token=token)
try:
resp = http_requests.get(
@ -451,8 +467,24 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
headers=headers,
timeout=15,
)
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()
headends = resp.json()
if not isinstance(headends, list):
headends = []
lineups = []
for headend in headends:
for lineup in headend.get('lineups', []):
@ -511,10 +543,22 @@ class ProgramViewSet(viewsets.ModelViewSet):
"""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
from apps.epg.sd_utils import (
SD_CODE_IMAGE_NOT_FOUND,
SD_IMAGE_LIMIT_CODES,
sd_handle_2055,
sd_headers_for_source,
sd_image_limit_active,
sd_mark_icon_missing,
sd_parse_response_payload,
sd_save_image_limit_lockout,
)
program = self.get_object()
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
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)
@ -522,6 +566,18 @@ class ProgramViewSet(viewsets.ModelViewSet):
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:
ProgramViewSet._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,
)
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
if error_cache and time.time() < error_cache['until']:
return Response(
@ -538,10 +594,24 @@ class ProgramViewSet(viewsets.ModelViewSet):
auth_resp = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': source.username, 'password': sha1_password},
headers=dispatcharr_http_headers(),
headers=sd_headers_for_source(source),
timeout=10,
)
auth_data = auth_resp.json()
try:
auth_data = auth_resp.json()
except ValueError:
auth_data = {}
if sd_handle_2055(source, auth_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,
)
token = auth_data.get('token')
if not token:
ProgramViewSet._sd_poster_error_cache[source.id] = {
@ -564,10 +634,44 @@ class ProgramViewSet(viewsets.ModelViewSet):
try:
img_resp = http_requests.get(
poster_sd_url,
headers=dispatcharr_http_headers(token=token, content_type=None),
headers=sd_headers_for_source(source, token=token, content_type=None),
timeout=15,
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)
ProgramViewSet._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):
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
ProgramViewSet._sd_poster_error_cache[source.id] = {
@ -575,22 +679,23 @@ class ProgramViewSet(viewsets.ModelViewSet):
'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)
# 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)
from django.http import HttpResponse
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

206
apps/epg/sd_utils.py Normal file
View file

@ -0,0 +1,206 @@
"""
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 json
import logging
from datetime import timedelta, timezone as dt_timezone
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from core.utils import dispatcharr_http_headers
logger = logging.getLogger(__name__)
# SD JSON error codes we must honor to avoid account blocks.
SD_CODE_INVALID_DEBUG = 2055
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,
})
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,
)

View file

@ -3038,10 +3038,10 @@ def fetch_schedules_direct(
# SD API spec requires the User-Agent to identify the application and version.
# SergeantPanda confirmed Dispatcharr should identify itself properly.
# -------------------------------------------------------------------------
from core.utils import dispatcharr_http_headers
from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source
def _sd_headers(token=None):
return dispatcharr_http_headers(token=token)
def _sd_headers(token=None, content_type='application/json'):
return sd_headers_for_source(source, token=token, content_type=content_type)
def _sd_post_refresh_tasks(mapped_epg_ids, program_metadata, today):
"""Poster fetch, logo auto-apply, and pruning — runs even when schedules are unchanged."""
@ -3056,6 +3056,12 @@ def fetch_schedules_direct(
| ~Q(custom_properties__has_key='sd_icon')
| ~Q(custom_properties__sd_poster_style=poster_style)
)
# Do not re-request artwork for URIs that SD already reported missing
# (code 5000), unless the user changed poster_style (explicit retry).
needs_poster_q = needs_poster_q & ~(
Q(custom_properties__sd_icon_missing=True)
& Q(custom_properties__sd_poster_style=poster_style)
)
poster_program_ids = set(
ProgramData.objects.filter(
epg_id__in=mapped_epg_ids,
@ -3138,6 +3144,7 @@ def fetch_schedules_direct(
cp = prog.custom_properties or {}
cp['sd_icon'] = poster
cp['sd_poster_style'] = poster_style
cp.pop('sd_icon_missing', None)
prog.custom_properties = cp
programs_to_update.append(prog)
if programs_to_update:
@ -3216,8 +3223,25 @@ def fetch_schedules_direct(
headers=_sd_headers(),
timeout=30,
)
try:
token_data = token_response.json()
except ValueError:
token_data = {}
if sd_handle_2055(source, token_data):
msg = (
"Schedules Direct rejected the debug routing header (code 2055). "
"Extra Schedules Direct Debugging has been turned off. Retry the "
"refresh unless Schedules Direct support asked you to enable it."
)
logger.error(msg)
source.status = EPGSource.STATUS_ERROR
source.last_message = msg
source.save(update_fields=['status', 'last_message'])
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
return
token_response.raise_for_status()
token_data = token_response.json()
auth_code = token_data.get('code', 0)
if auth_code != 0:

View file

@ -14,6 +14,7 @@ Covers:
"""
import hashlib
import time
from datetime import date, datetime, timedelta, timezone as dt_timezone
from unittest.mock import MagicMock, patch
@ -1355,3 +1356,258 @@ class SDPosterSelectionTests(TestCase):
_sd_pick_poster_url(images, 'square_iconic'),
'assets/landscape_primary.jpg',
)
# ---------------------------------------------------------------------------
# SD helpers and poster proxy error handling
# ---------------------------------------------------------------------------
class SDUtilsTests(TestCase):
"""Unit tests for apps.epg.sd_utils helpers."""
def test_headers_include_routeto_when_extra_debugging_enabled(self):
from apps.epg.sd_utils import sd_headers_for_source
source = EPGSource.objects.create(
name='SD Debug Headers',
source_type='schedules_direct',
username='u',
password='p',
custom_properties={'sd_extra_debugging': True},
)
headers = sd_headers_for_source(source, token='tok', content_type=None)
self.assertEqual(headers.get('RouteTo'), 'debug')
self.assertEqual(headers.get('token'), 'tok')
self.assertNotIn('Content-Type', headers)
def test_headers_omit_routeto_by_default(self):
from apps.epg.sd_utils import sd_headers_for_source
source = EPGSource.objects.create(
name='SD No Debug',
source_type='schedules_direct',
username='u',
password='p',
)
headers = sd_headers_for_source(source)
self.assertNotIn('RouteTo', headers)
def test_2055_disables_extra_debugging(self):
from apps.epg.sd_utils import sd_handle_2055
source = EPGSource.objects.create(
name='SD 2055',
source_type='schedules_direct',
username='u',
password='p',
custom_properties={'sd_extra_debugging': True},
)
self.assertTrue(sd_handle_2055(source, {
'response': 'INVALID_PARAMETER:DEBUG',
'code': 2055,
'message': 'Unexpected debug connection from client.',
}))
source.refresh_from_db()
self.assertFalse(source.custom_properties.get('sd_extra_debugging'))
def test_image_limit_lockout_persists_until_midnight_utc(self):
from apps.epg.sd_utils import (
sd_image_limit_active,
sd_next_midnight_utc,
sd_save_image_limit_lockout,
)
source = EPGSource.objects.create(
name='SD Limit',
source_type='schedules_direct',
username='u',
password='p',
)
sd_save_image_limit_lockout(source, 5002)
source.refresh_from_db()
active, reason = sd_image_limit_active(source)
self.assertTrue(active)
self.assertIn('5002', reason)
self.assertEqual(
source.custom_properties.get('sd_image_limit_reset_at'),
sd_next_midnight_utc().isoformat(),
)
class SDPosterProxyErrorHandlingTests(TestCase):
"""Poster proxy must honor SD image error codes so accounts are not blocked."""
def setUp(self):
from apps.epg.api_views import ProgramViewSet
from rest_framework.test import APIClient
ProgramViewSet._sd_poster_token_cache.clear()
ProgramViewSet._sd_poster_error_cache.clear()
self.client = APIClient()
self.source = EPGSource.objects.create(
name='SD Poster Source',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
self.epg = EPGData.objects.create(
tvg_id='station1',
name='Station 1',
epg_source=self.source,
)
self.program = ProgramData.objects.create(
epg=self.epg,
title='Show',
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
program_id='EP123456789012',
custom_properties={
'sd_icon': 'https://json.schedulesdirect.org/20141201/image/assets/test.jpg',
},
)
self.url = f'/api/epg/programs/{self.program.id}/poster/'
def tearDown(self):
from apps.epg.api_views import ProgramViewSet
ProgramViewSet._sd_poster_token_cache.clear()
ProgramViewSet._sd_poster_error_cache.clear()
def _auth_ok(self):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
'code': 0,
'token': 'poster-tok',
'tokenExpires': time.time() + 86400,
}),
)
def _json_response(self, status_code, payload, content_type='application/json'):
import json as json_mod
body = json_mod.dumps(payload).encode('utf-8')
resp = MagicMock()
resp.status_code = status_code
resp.headers = {'Content-Type': content_type}
resp.content = body
resp.json = MagicMock(return_value=payload)
return resp
@patch('requests.get')
@patch('requests.post')
def test_http_200_json_5002_locks_until_midnight_and_blocks_retry(
self, mock_post, mock_get
):
mock_post.return_value = self._auth_ok()
mock_get.return_value = self._json_response(200, {
'response': 'MAX_IMAGE_DOWNLOADS',
'code': 5002,
'message': 'Maximum image downloads reached.',
})
first = self.client.get(self.url)
self.assertEqual(first.status_code, 429)
self.source.refresh_from_db()
self.assertTrue(self.source.custom_properties.get('sd_image_limit_hit'))
mock_get.reset_mock()
second = self.client.get(self.url)
self.assertEqual(second.status_code, 503)
mock_get.assert_not_called()
@patch('requests.get')
@patch('requests.post')
def test_http_200_json_5003_locks_out(self, mock_post, mock_get):
mock_post.return_value = self._auth_ok()
mock_get.return_value = self._json_response(200, {
'response': 'MAX_IMAGE_DOWNLOADS_TRIAL',
'code': 5003,
'message': 'Maximum image downloads for trial user reached.',
})
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 429)
self.source.refresh_from_db()
self.assertTrue(self.source.custom_properties.get('sd_image_limit_hit'))
self.assertIn('5003', self.source.custom_properties.get('sd_image_limit_reason', ''))
@patch('requests.get')
@patch('requests.post')
def test_http_404_json_5000_clears_sd_icon(self, mock_post, mock_get):
mock_post.return_value = self._auth_ok()
mock_get.return_value = self._json_response(404, {
'response': 'IMAGE_NOT_FOUND',
'code': 5000,
'message': 'Could not find requested image.',
})
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 404)
self.program.refresh_from_db()
cp = self.program.custom_properties or {}
self.assertNotIn('sd_icon', cp)
self.assertTrue(cp.get('sd_icon_missing'))
mock_get.reset_mock()
again = self.client.get(self.url)
self.assertEqual(again.status_code, 404)
mock_get.assert_not_called()
@patch('requests.get')
@patch('requests.post')
def test_bare_http_404_does_not_clear_sd_icon(self, mock_post, mock_get):
"""Transient CDN/S3 404 without SD code 5000 must not blacklist the URI."""
mock_post.return_value = self._auth_ok()
bare_404 = MagicMock()
bare_404.status_code = 404
bare_404.headers = {'Content-Type': 'text/plain'}
bare_404.content = b'Not Found'
bare_404.json = MagicMock(side_effect=ValueError('not json'))
mock_get.return_value = bare_404
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 404)
self.program.refresh_from_db()
cp = self.program.custom_properties or {}
self.assertIn('sd_icon', cp)
self.assertFalse(cp.get('sd_icon_missing'))
@patch('requests.get')
@patch('requests.post')
def test_2055_disables_extra_debugging_on_auth(self, mock_post, mock_get):
self.source.custom_properties = {'sd_extra_debugging': True}
self.source.save(update_fields=['custom_properties'])
mock_post.return_value = self._json_response(200, {
'response': 'INVALID_PARAMETER:DEBUG',
'code': 2055,
'message': 'Unexpected debug connection from client.',
})
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 400)
self.source.refresh_from_db()
self.assertFalse(self.source.custom_properties.get('sd_extra_debugging'))
mock_get.assert_not_called()
auth_headers = mock_post.call_args.kwargs.get('headers') or mock_post.call_args[1].get('headers')
self.assertEqual(auth_headers.get('RouteTo'), 'debug')
@patch('requests.get')
@patch('requests.post')
def test_successful_image_pass_through(self, mock_post, mock_get):
mock_post.return_value = self._auth_ok()
img = MagicMock()
img.status_code = 200
img.headers = {'Content-Type': 'image/jpeg'}
img.content = b'\xff\xd8\xffjpeg-bytes'
img.json = MagicMock(side_effect=ValueError('not json'))
mock_get.return_value = img
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp['Content-Type'], 'image/jpeg')
self.assertEqual(resp.content, b'\xff\xd8\xffjpeg-bytes')

View file

@ -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):

View file

@ -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

View file

@ -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">
@ -290,6 +299,17 @@ const SDSettings = ({ sourceId, customProperties }) => {
allowDeselect={false}
/>
)}
<Divider my="sm" />
<Switch
label="Extra Schedules Direct Debugging"
description="Only turn this on if Schedules Direct support asks you to. Adds a RouteTo: debug header so SD can steer requests to their debug server. If SD returns code 2055, this setting is turned off automatically."
checked={extraDebugging}
onChange={(e) => handleExtraDebuggingToggle(e.currentTarget.checked)}
disabled={saving}
size="sm"
/>
</Box>
);
};