diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ab49b8..b6101b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. - **Schedules Direct auth and lineup change handling better match SD guidelines.** Token codes 3000/3001 (offline/busy) stop as idle with clear "do not retry" messaging instead of looking like credential failures; 4010 (too many unique IPs) and related account codes get explicit messages. Lineup add/delete respects a known daily change lockout before calling SD, handles 4100 on deletes, and the UI blocks remove when no changes remain (adds and deletes both count toward the 6/day limit). -- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. +- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. Redis-cached SD tokens are bound to a credential fingerprint (and cleared when the EPG source username/password change) so switching accounts cannot reuse another session. - **Schedules Direct code split out of general EPG modules.** Refresh pipeline and Celery tasks live in `apps/epg/sd_tasks.py`; lineup/poster API mixins in `apps/epg/sd_api.py`; shared protocol helpers remain in `sd_utils.py`. Progress WebSocket updates (`send_epg_update`) live in `apps/epg/utils.py` so XMLTV and SD tasks can import cleanly. `tasks.py` / `api_views.py` re-export or inherit so existing imports and Celery task names are unchanged. ## [0.28.1] - 2026-07-20 diff --git a/apps/epg/sd_api.py b/apps/epg/sd_api.py index b80285a5..577d72bd 100644 --- a/apps/epg/sd_api.py +++ b/apps/epg/sd_api.py @@ -35,7 +35,6 @@ from apps.epg.sd_utils import ( sd_obtain_token, sd_parse_response_payload, sd_save_image_limit_lockout, - sd_set_cached_token, ) from core.utils import dispatcharr_http_headers @@ -472,7 +471,9 @@ class SchedulesDirectPosterMixin: status=status.HTTP_503_SERVICE_UNAVAILABLE, ) - token = sd_get_cached_token(source.id) + token = sd_get_cached_token( + source.id, username=source.username, password=source.password + ) if not token: auth = sd_obtain_token(source, timeout=10) @@ -495,7 +496,6 @@ class SchedulesDirectPosterMixin: ) token = auth.token self._sd_poster_error_cache.pop(source.id, None) - sd_set_cached_token(source.id, token, auth.token_expires) try: img_resp = http_requests.get( diff --git a/apps/epg/sd_utils.py b/apps/epg/sd_utils.py index d8244d5f..3198bc5f 100644 --- a/apps/epg/sd_utils.py +++ b/apps/epg/sd_utils.py @@ -96,7 +96,7 @@ def sd_auth_lockout_retry_message(): # Shared across uWSGI workers via Django's Redis cache. -_SD_TOKEN_CACHE_PREFIX = 'sd:token:v1:' +_SD_TOKEN_CACHE_PREFIX = 'sd:token:' # 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 @@ -106,14 +106,28 @@ def sd_token_cache_key(source_id): return f'{_SD_TOKEN_CACHE_PREFIX}{source_id}' -def sd_get_cached_token(source_id): +def sd_credential_fingerprint(username, password): + """ + Stable fingerprint of SD credentials for lockout and token-cache matching. + + Used so a persisted auth lockout or cached token clears automatically when + the user changes username or password. + """ + raw = f'{(username or "").strip()}\0{(password or "").strip()}' + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def sd_get_cached_token(source_id, username=None, password=None): """ Return a cached SD token string for this source, or None. + Requires ``username`` / ``password`` so a cached session is only reused when + credentials still match (avoids using account A's token after switching to B). On Redis failure, returns None so the caller re-authenticates. """ if source_id is None: return None + current_fp = sd_credential_fingerprint(username, password) try: payload = cache.get(sd_token_cache_key(source_id)) except Exception as exc: @@ -129,16 +143,20 @@ def sd_get_cached_token(source_id): expires = payload.get('expires') if not token or not isinstance(expires, (int, float)): return None + if payload.get('fp') != current_fp: + sd_clear_cached_token(source_id) + 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): +def sd_set_cached_token(source_id, token, expires=None, username=None, password=None): """ Cache an SD token for this source until near tokenExpires. ``expires`` is a UNIX epoch seconds value from SD's tokenExpires field. + Stores a credential fingerprint so callers must still match to reuse it. """ if source_id is None or not token: return False @@ -155,7 +173,11 @@ def sd_set_cached_token(source_id, token, expires=None): try: cache.set( sd_token_cache_key(source_id), - {'token': token, 'expires': expires}, + { + 'token': token, + 'expires': expires, + 'fp': sd_credential_fingerprint(username, password), + }, timeout=ttl, ) return True @@ -184,17 +206,6 @@ def sd_clear_cached_token(source_id): return False -def sd_credential_fingerprint(username, password): - """ - Stable fingerprint of SD credentials for lockout matching. - - Used so a persisted auth lockout clears automatically when the user - changes username or password, without requiring a separate clear hook. - """ - raw = f'{(username or "").strip()}\0{(password or "").strip()}' - return hashlib.sha256(raw.encode('utf-8')).hexdigest() - - def sd_auth_failure_message(code, sd_message=None): """Human-readable message for a Schedules Direct /token error code.""" if code == SD_CODE_SERVICE_OFFLINE: @@ -494,10 +505,14 @@ def sd_obtain_token(source, username=None, password=None, *, timeout=30): expires = auth_data.get('tokenExpires') if not isinstance(expires, (int, float)): expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS + expires = float(expires) + sd_set_cached_token( + source.id, token, expires, username=username, password=password + ) return SDTokenAuthResult( ok=True, token=token, - token_expires=float(expires), + token_expires=expires, code=0, ) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index ed99efb4..9d8c05b2 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -2,6 +2,10 @@ from core.utils import validate_flexible_url, build_absolute_uri_with_port from rest_framework import serializers from .models import EPGSource, EPGData, ProgramData from apps.channels.models import Channel, Stream +from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_credential_fingerprint, +) class EPGSourceSerializer(serializers.ModelSerializer): epg_data_count = serializers.SerializerMethodField() @@ -67,11 +71,16 @@ class EPGSourceSerializer(serializers.ModelSerializer): ct = instance.refresh_task.crontab cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' instance._cron_expression = cron_expr + prior_fp = sd_credential_fingerprint(instance.username, instance.password) for attr, value in validated_data.items(): if attr == 'password' and not value: continue setattr(instance, attr, value) instance.save() + # Drop any Redis SD session tied to the previous username/password so + # poster traffic cannot keep using another account's token. + if prior_fp != sd_credential_fingerprint(instance.username, instance.password): + sd_clear_cached_token(instance.id) return instance def create(self, validated_data): diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index fe336967..8510ccd3 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -1789,12 +1789,18 @@ class SDUtilsTests(TestCase): ) source_id = 4242 + user, password = 'sduser', 'sdpass' 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') + self.assertIsNone(sd_get_cached_token(source_id, user, password)) + self.assertTrue( + sd_set_cached_token( + source_id, 'tok-abc', time.time() + 3600, + username=user, password=password, + ) + ) + self.assertEqual(sd_get_cached_token(source_id, user, password), 'tok-abc') sd_clear_cached_token(source_id) - self.assertIsNone(sd_get_cached_token(source_id)) + self.assertIsNone(sd_get_cached_token(source_id, user, password)) def test_token_cache_ignores_near_expiry(self): from apps.epg.sd_utils import ( @@ -1804,10 +1810,78 @@ class SDUtilsTests(TestCase): ) source_id = 4243 + user, password = 'sduser', 'sdpass' 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)) + self.assertFalse( + sd_set_cached_token( + source_id, 'tok-soon', time.time() + 30, + username=user, password=password, + ) + ) + self.assertIsNone(sd_get_cached_token(source_id, user, password)) + + def test_token_cache_misses_after_username_change(self): + """Cached token must not be reused after switching SD accounts.""" + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source_id = 4244 + sd_clear_cached_token(source_id) + self.assertTrue( + sd_set_cached_token( + source_id, 'tok-account-a', time.time() + 3600, + username='account-a', password='pass-a', + ) + ) + self.assertEqual( + sd_get_cached_token(source_id, 'account-a', 'pass-a'), + 'tok-account-a', + ) + self.assertIsNone( + sd_get_cached_token(source_id, 'account-b', 'pass-a') + ) + # Mismatch clears the stale entry so account B cannot resurrect it. + self.assertIsNone( + sd_get_cached_token(source_id, 'account-a', 'pass-a') + ) + + def test_serializer_username_change_clears_token_cache(self): + """Updating an EPG source username must drop the Redis SD token.""" + from apps.epg.serializers import EPGSourceSerializer + from apps.epg.sd_utils import ( + sd_clear_cached_token, + sd_get_cached_token, + sd_set_cached_token, + ) + + source = EPGSource.objects.create( + name='SD Token Clear', + source_type='schedules_direct', + username='account-a', + password='pass-a', + ) + sd_clear_cached_token(source.id) + self.assertTrue( + sd_set_cached_token( + source.id, 'tok-a', time.time() + 3600, + username='account-a', password='pass-a', + ) + ) + serializer = EPGSourceSerializer( + source, data={'username': 'account-b'}, partial=True + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + self.assertIsNone( + sd_get_cached_token(source.id, 'account-a', 'pass-a') + ) + self.assertIsNone( + sd_get_cached_token(source.id, 'account-b', 'pass-a') + ) class SDPosterProxyErrorHandlingTests(TestCase):