From dc59469b21ba95e849c2231e5d736fb6cb039de2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 20 Jul 2026 23:09:39 +0000 Subject: [PATCH] feat(epg): Implement Redis caching for Schedules Direct tokens to optimize authentication This commit introduces a caching mechanism for Schedules Direct authentication tokens using Django's Redis cache. The new implementation allows concurrent requests to reuse tokens across uWSGI workers, reducing the number of authentication calls. Additionally, helper functions for setting, getting, and clearing cached tokens have been added, along with tests to ensure proper functionality and handling of token expiration. This enhancement improves performance and efficiency in managing Schedules Direct API interactions. --- CHANGELOG.md | 1 + apps/epg/api_views.py | 21 +++--- apps/epg/sd_utils.py | 90 +++++++++++++++++++++++++ apps/epg/tests/test_schedules_direct.py | 53 ++++++++++++++- 4 files changed, 152 insertions(+), 13 deletions(-) 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) +