mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
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:
parent
18b41a57e2
commit
dc59469b21
4 changed files with 152 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue