diff --git a/CHANGELOG.md b/CHANGELOG.md index 98444ff4..3dccc994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 1252bcbc..db5e4fb4 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -558,8 +558,8 @@ class ProgramViewSet(viewsets.ModelViewSet): queryset = ProgramData.objects.select_related("epg").all() serializer_class = ProgramDataSerializer - # Per-source in-memory caches (token and error state) - _sd_poster_token_cache: dict = {} + # Short process-local cooldown for transient poster errors (auth/network). + # Image download limits are persisted on the EPG source (shared across workers). _sd_poster_error_cache: dict = {} def get_permissions(self): @@ -588,12 +588,15 @@ class ProgramViewSet(viewsets.ModelViewSet): from apps.epg.sd_utils import ( SD_CODE_IMAGE_NOT_FOUND, SD_IMAGE_LIMIT_CODES, + sd_clear_cached_token, + sd_get_cached_token, sd_handle_2055, sd_headers_for_source, sd_image_limit_active, sd_mark_icon_missing, sd_parse_response_payload, sd_save_image_limit_lockout, + sd_set_cached_token, ) program = self.get_object() @@ -627,8 +630,7 @@ class ProgramViewSet(viewsets.ModelViewSet): 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 + token = sd_get_cached_token(source.id) if not token: sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest() @@ -661,11 +663,10 @@ class ProgramViewSet(viewsets.ModelViewSet): '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, - } + token_expires = auth_data.get( + 'tokenExpires', time.time() + 86400 + ) + sd_set_cached_token(source.id, token, token_expires) except http_requests.exceptions.RequestException: ProgramViewSet._sd_poster_error_cache[source.id] = { 'until': time.time() + 300, @@ -715,7 +716,7 @@ class ProgramViewSet(viewsets.ModelViewSet): 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) + sd_clear_cached_token(source.id) ProgramViewSet._sd_poster_error_cache[source.id] = { 'until': time.time() + 3600, 'reason': f'SD returned {img_resp.status_code}', diff --git a/apps/epg/sd_utils.py b/apps/epg/sd_utils.py index bbba74e3..57170692 100644 --- a/apps/epg/sd_utils.py +++ b/apps/epg/sd_utils.py @@ -8,8 +8,10 @@ from __future__ import annotations import json import logging +import time from datetime import timedelta, timezone as dt_timezone +from django.core.cache import cache from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -28,6 +30,94 @@ SD_IMAGE_LIMIT_CODES = frozenset({ SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL, }) +# Shared across uWSGI workers via Django's Redis cache. +_SD_TOKEN_CACHE_PREFIX = 'sd:token:v1:' +# 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_get_cached_token(source_id): + """ + Return a cached SD token string for this source, or None. + + On Redis failure, returns None so the caller re-authenticates. + """ + if source_id is None: + return None + 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 time.time() >= float(expires) - _SD_TOKEN_CACHE_SKEW_SECONDS: + return None + return token + + +def sd_set_cached_token(source_id, token, expires=None): + """ + Cache an SD token for this source until near tokenExpires. + + ``expires`` is a UNIX epoch seconds value from SD's tokenExpires field. + """ + 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}, + 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_next_midnight_utc(): """Return the next Schedules Direct counter reset (00:00Z).""" diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index 7fb63f9f..044df3e9 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -1551,17 +1551,44 @@ class SDUtilsTests(TestCase): sd_next_midnight_utc().isoformat(), ) + def test_token_cache_round_trip(self): + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source_id = 4242 + sd_clear_cached_token(source_id) + self.assertIsNone(sd_get_cached_token(source_id)) + self.assertTrue(sd_set_cached_token(source_id, 'tok-abc', time.time() + 3600)) + self.assertEqual(sd_get_cached_token(source_id), 'tok-abc') + sd_clear_cached_token(source_id) + self.assertIsNone(sd_get_cached_token(source_id)) + + def test_token_cache_ignores_near_expiry(self): + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source_id = 4243 + sd_clear_cached_token(source_id) + # Within skew window: set should refuse or get should miss. + self.assertFalse(sd_set_cached_token(source_id, 'tok-soon', time.time() + 30)) + self.assertIsNone(sd_get_cached_token(source_id)) + 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 apps.epg.sd_utils import sd_clear_cached_token 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', @@ -1569,6 +1596,7 @@ class SDPosterProxyErrorHandlingTests(TestCase): username='sduser', password='sdpass', ) + sd_clear_cached_token(self.source.id) self.epg = EPGData.objects.create( tvg_id='station1', name='Station 1', @@ -1588,8 +1616,10 @@ class SDPosterProxyErrorHandlingTests(TestCase): def tearDown(self): from apps.epg.api_views import ProgramViewSet - ProgramViewSet._sd_poster_token_cache.clear() + from apps.epg.sd_utils import sd_clear_cached_token + ProgramViewSet._sd_poster_error_cache.clear() + sd_clear_cached_token(self.source.id) def _auth_ok(self): return MagicMock( @@ -1729,3 +1759,20 @@ class SDPosterProxyErrorHandlingTests(TestCase): self.assertEqual(resp['Content-Type'], 'image/jpeg') self.assertEqual(resp.content, b'\xff\xd8\xffjpeg-bytes') + @patch('requests.get') + @patch('requests.post') + def test_second_poster_request_reuses_cached_token(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 + + self.assertEqual(self.client.get(self.url).status_code, 200) + mock_post.reset_mock() + self.assertEqual(self.client.get(self.url).status_code, 200) + mock_post.assert_not_called() + self.assertEqual(mock_get.call_count, 2) +