From ac38adcfce29efb86b147d7fe411a845dff56061 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 20 Jul 2026 21:38:34 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 5 + apps/epg/api_views.py | 153 +++++++++++--- apps/epg/sd_utils.py | 206 +++++++++++++++++++ apps/epg/tasks.py | 32 ++- apps/epg/tests/test_schedules_direct.py | 256 ++++++++++++++++++++++++ core/tests/test_core.py | 9 + core/utils.py | 6 +- frontend/src/components/forms/EPG.jsx | 20 ++ 8 files changed, 658 insertions(+), 29 deletions(-) create mode 100644 apps/epg/sd_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f799c2f9..196e9c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index acd834b2..2d28fedf 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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 diff --git a/apps/epg/sd_utils.py b/apps/epg/sd_utils.py new file mode 100644 index 00000000..bbba74e3 --- /dev/null +++ b/apps/epg/sd_utils.py @@ -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, + ) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 7e145bd1..8ef0d58f 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -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: diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index 52a59330..eea85263 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -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') + diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 5c337e24..e652c9d5 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -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): diff --git a/core/utils.py b/core/utils.py index 5b283703..fb88a330 100644 --- a/core/utils.py +++ b/core/utils.py @@ -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 diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 7b8118de..caa427c8 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -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 ( @@ -290,6 +299,17 @@ const SDSettings = ({ sourceId, customProperties }) => { allowDeselect={false} /> )} + + + + handleExtraDebuggingToggle(e.currentTarget.checked)} + disabled={saving} + size="sm" + /> ); };