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.
This commit is contained in:
SergeantPanda 2026-07-20 23:09:39 +00:00
parent 18b41a57e2
commit dc59469b21
4 changed files with 152 additions and 13 deletions

View file

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

View file

@ -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}',

View file

@ -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)."""

View file

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