diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dccc994..1f854063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +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. ## [0.28.1] - 2026-07-20 diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index db5e4fb4..780a0bf7 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,4 +1,3 @@ -import hashlib import logging import os import re @@ -130,56 +129,31 @@ class EPGSourceViewSet(viewsets.ModelViewSet): Authenticate with Schedules Direct using stored credentials. Returns (token, None) on success or (None, Response) on failure. """ - import hashlib - import requests as http_requests - from apps.epg.tasks import SD_BASE_URL - from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source + from apps.epg.sd_utils import ( + SD_AUTH_CREDENTIAL_LOCKOUT_CODES, + sd_obtain_token, + ) - username = (source.username or '').strip() - password = (source.password or '').strip() - if not username or not password: + result = sd_obtain_token(source, timeout=15) + if result.ok: + return result.token, None + + if result.debug_rejected: return None, Response( - {"error": "Username and password are required."}, - status=status.HTTP_400_BAD_REQUEST + {"error": result.message}, + status=status.HTTP_400_BAD_REQUEST, ) - sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest() - try: - auth_response = http_requests.post( - f"{SD_BASE_URL}/token", - json={'username': username, 'password': sha1_password}, - headers=sd_headers_for_source(source), - timeout=15, - ) - try: - auth_data = auth_response.json() - except ValueError: - auth_data = {} - if sd_handle_2055(source, auth_data): - return None, Response( - { - "error": ( - "Schedules Direct rejected the debug routing header " - "(code 2055). Extra Schedules Direct Debugging has " - "been turned off. Retry without it unless SD support " - "asked you to enable it." - ), - }, - status=status.HTTP_400_BAD_REQUEST, - ) - auth_response.raise_for_status() - token = auth_data.get('token') - if not token: - return None, Response( - {"error": "Authentication failed. Check your credentials."}, - status=status.HTTP_401_UNAUTHORIZED - ) - return token, None - except http_requests.exceptions.RequestException as e: - return None, Response( - {"error": f"Authentication failed: {str(e)}"}, - status=status.HTTP_502_BAD_GATEWAY - ) + if result.message == 'Username and password are required.': + http_status = status.HTTP_400_BAD_REQUEST + elif result.code in SD_AUTH_CREDENTIAL_LOCKOUT_CODES: + http_status = status.HTTP_401_UNAUTHORIZED + elif result.lockout or result.soft or result.code: + http_status = status.HTTP_503_SERVICE_UNAVAILABLE + else: + http_status = status.HTTP_502_BAD_GATEWAY + + return None, Response({"error": result.message}, status=http_status) def _get_sd_reset_at(self, source): """Retrieve stored reset timestamp from EPGSource model field.""" @@ -584,16 +558,17 @@ class ProgramViewSet(viewsets.ModelViewSet): def poster(self, request, pk=None): """Proxy endpoint for SD program poster images. Nginx caches the response.""" import requests as http_requests - from apps.epg.tasks import SD_BASE_URL from apps.epg.sd_utils import ( SD_CODE_IMAGE_NOT_FOUND, SD_IMAGE_LIMIT_CODES, + sd_auth_lockout_active, sd_clear_cached_token, sd_get_cached_token, sd_handle_2055, sd_headers_for_source, sd_image_limit_active, sd_mark_icon_missing, + sd_obtain_token, sd_parse_response_payload, sd_save_image_limit_lockout, sd_set_cached_token, @@ -623,6 +598,18 @@ class ProgramViewSet(viewsets.ModelViewSet): status=status.HTTP_503_SERVICE_UNAVAILABLE, ) + auth_lockout_active, auth_lockout_reason, _lockout_code = ( + sd_auth_lockout_active(source) + ) + if auth_lockout_active: + # Rely on the persisted lockout only. Do not seed the process-local + # poster error cache here: that cache outlives credential changes and + # cooldown expiry and would keep returning 503 afterward. + return Response( + {'error': auth_lockout_reason}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id) if error_cache and time.time() < error_cache['until']: return Response( @@ -633,46 +620,27 @@ class ProgramViewSet(viewsets.ModelViewSet): token = sd_get_cached_token(source.id) if not token: - sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest() - try: - auth_resp = http_requests.post( - f"{SD_BASE_URL}/token", - json={'username': source.username, 'password': sha1_password}, - headers=sd_headers_for_source(source), - timeout=10, + auth = sd_obtain_token(source, timeout=10) + if auth.debug_rejected: + return Response( + {'error': auth.message}, + status=status.HTTP_400_BAD_REQUEST, ) - try: - auth_data = auth_resp.json() - except ValueError: - auth_data = {} - if sd_handle_2055(source, auth_data): - return Response( - { - 'error': ( - 'Schedules Direct rejected the debug routing header ' - '(code 2055). Extra Schedules Direct Debugging has ' - 'been turned off.' - ), - }, - status=status.HTTP_400_BAD_REQUEST, - ) - token = auth_data.get('token') - if not token: + if not auth.ok: + # Lockout codes are persisted by sd_obtain_token. Other failures + # use a short process-local cache so workers do not hammer /token. + if not auth.lockout: ProgramViewSet._sd_poster_error_cache[source.id] = { - 'until': time.time() + 3600, - 'reason': auth_data.get('message', 'Authentication failed'), + 'until': time.time() + (3600 if auth.code else 300), + 'reason': auth.message or 'Authentication failed', } - return Response(status=status.HTTP_502_BAD_GATEWAY) - token_expires = auth_data.get( - 'tokenExpires', time.time() + 86400 + return Response( + {'error': auth.message}, + status=status.HTTP_502_BAD_GATEWAY, ) - 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, - 'reason': 'Network error reaching Schedules Direct', - } - return Response(status=status.HTTP_502_BAD_GATEWAY) + token = auth.token + ProgramViewSet._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 57170692..d8244d5f 100644 --- a/apps/epg/sd_utils.py +++ b/apps/epg/sd_utils.py @@ -6,11 +6,14 @@ See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201 from __future__ import annotations +import hashlib import json import logging import time +from dataclasses import dataclass from datetime import timedelta, timezone as dt_timezone +import requests from django.core.cache import cache from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -19,8 +22,21 @@ from core.utils import dispatcharr_http_headers logger = logging.getLogger(__name__) +SD_BASE_URL = 'https://json.schedulesdirect.org/20141201' + # SD JSON error codes we must honor to avoid account blocks. SD_CODE_INVALID_DEBUG = 2055 +SD_CODE_SERVICE_OFFLINE = 3000 +SD_CODE_SERVICE_BUSY = 3001 +SD_CODE_ACCOUNT_EXPIRED = 4001 +SD_CODE_INVALID_PASSWORD_HASH = 4002 +SD_CODE_INVALID_USER_OR_PASSWORD = 4003 +SD_CODE_ACCOUNT_LOCKED = 4004 +SD_CODE_JSON_DISABLED = 4005 +SD_CODE_APP_NOT_AUTHORIZED = 4007 +SD_CODE_ACCOUNT_INACTIVE = 4008 +SD_CODE_TOO_MANY_LOGINS = 4009 +SD_CODE_TOO_MANY_UNIQUE_IPS = 4010 SD_CODE_IMAGE_NOT_FOUND = 5000 SD_CODE_MAX_IMAGE_DOWNLOADS = 5002 SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL = 5003 @@ -30,6 +46,55 @@ SD_IMAGE_LIMIT_CODES = frozenset({ SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL, }) +# Soft /token failures: stop and retry later (idle), not a hard account error. +SD_AUTH_SOFT_CODES = frozenset({ + SD_CODE_SERVICE_OFFLINE, + SD_CODE_SERVICE_BUSY, +}) + +# Wrong username/password (or hash): clear early when credentials change. +SD_AUTH_CREDENTIAL_LOCKOUT_CODES = frozenset({ + SD_CODE_INVALID_PASSWORD_HASH, + SD_CODE_INVALID_USER_OR_PASSWORD, +}) + +# All /token codes we must not hammer. Includes soft codes (shorter cooldown). +SD_AUTH_LOCKOUT_CODES = frozenset({ + SD_CODE_SERVICE_OFFLINE, + SD_CODE_SERVICE_BUSY, + SD_CODE_ACCOUNT_EXPIRED, + SD_CODE_INVALID_PASSWORD_HASH, + SD_CODE_INVALID_USER_OR_PASSWORD, + SD_CODE_ACCOUNT_LOCKED, + SD_CODE_JSON_DISABLED, + SD_CODE_APP_NOT_AUTHORIZED, + SD_CODE_ACCOUNT_INACTIVE, + SD_CODE_TOO_MANY_LOGINS, + SD_CODE_TOO_MANY_UNIQUE_IPS, +}) + +SD_AUTH_LOCKOUT_SECONDS = 24 * 3600 +SD_AUTH_LOCKOUT_SECONDS_SOFT = 3600 +SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK = 15 * 60 + + +def sd_auth_lockout_seconds_for_code(code): + """Cooldown length for a /token error code.""" + if code == SD_CODE_ACCOUNT_LOCKED: + return SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK + if code in SD_AUTH_SOFT_CODES: + return SD_AUTH_LOCKOUT_SECONDS_SOFT + return SD_AUTH_LOCKOUT_SECONDS + + +def sd_auth_lockout_retry_message(): + """Suffix explaining how to clear an auth lockout.""" + return ( + "Not retrying Schedules Direct authentication until the username or " + "password is updated, or the cooldown expires." + ) + + # 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. @@ -119,6 +184,324 @@ 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: + return ( + "Schedules Direct is offline for maintenance. " + "Do not retry for at least 1 hour." + ) + if code == SD_CODE_SERVICE_BUSY: + return "Schedules Direct is busy. Stop requesting and retry later." + if code == SD_CODE_ACCOUNT_EXPIRED: + return ( + "Schedules Direct: account has expired. Please renew your " + "subscription at schedulesdirect.org." + ) + if code == SD_CODE_INVALID_PASSWORD_HASH: + return ( + "Schedules Direct: invalid password hash. Check that credentials " + "are stored correctly." + ) + if code == SD_CODE_INVALID_USER_OR_PASSWORD: + return "Schedules Direct: invalid username or password." + if code == SD_CODE_ACCOUNT_LOCKED: + return ( + "Schedules Direct: account locked due to too many failed login " + "attempts. Try again in 15 minutes." + ) + if code == SD_CODE_JSON_DISABLED: + return ( + "Schedules Direct: JSON API access is disabled for this account. " + "Contact Schedules Direct support." + ) + if code == SD_CODE_APP_NOT_AUTHORIZED: + return ( + "Schedules Direct: this application is not authorized. Please " + "contact the Dispatcharr maintainers." + ) + if code == SD_CODE_ACCOUNT_INACTIVE: + return ( + "Schedules Direct: account is inactive. Please log in to " + "schedulesdirect.org to reactivate." + ) + if code == SD_CODE_TOO_MANY_LOGINS: + return ( + "Schedules Direct: too many login attempts in 24 hours. Token is " + "valid for 24 hours. Check for misconfiguration." + ) + if code == SD_CODE_TOO_MANY_UNIQUE_IPS: + return ( + "Schedules Direct: too many unique IP addresses in 24 hours. " + "Avoid VPNs or multiple locations, or contact SD support to raise " + "the limit." + ) + if sd_message: + return f"Schedules Direct authentication failed (code {code}): {sd_message}" + return f"Schedules Direct authentication failed (code {code})." + + +def sd_token_response_code(auth_data): + """Return integer SD ``code`` from a /token JSON body, or 0.""" + if not isinstance(auth_data, dict): + return 0 + code = auth_data.get('code', 0) + return code if isinstance(code, int) else 0 + + +def sd_auth_lockout_active(source, username=None, password=None): + """ + Return (active, reason, code) for a persisted auth lockout. + + Cleared when username/password changed, or sd_auth_lockout_until has passed. + """ + if source is None: + return False, None, None + + cp = source.custom_properties or {} + if not cp.get('sd_auth_lockout'): + return False, None, None + + until_str = cp.get('sd_auth_lockout_until') + until = parse_datetime(until_str) if until_str else None + if until is None or timezone.now() >= until: + sd_clear_auth_lockout(source) + return False, None, None + + if username is None: + username = source.username + if password is None: + password = source.password + + stored_fp = cp.get('sd_auth_lockout_credential_fp') + current_fp = sd_credential_fingerprint(username, password) + if not stored_fp or stored_fp != current_fp: + sd_clear_auth_lockout(source) + return False, None, None + + code = cp.get('sd_auth_lockout_code') + reason = cp.get('sd_auth_lockout_reason') or ( + 'Schedules Direct authentication is temporarily blocked.' + ) + return True, reason, code if isinstance(code, int) else None + + +def sd_save_auth_lockout(source, code, username=None, password=None, reason=None): + """ + Persist an auth lockout so we stop calling /token for a cooldown period. + + Duration depends on the code (15m for 4004, 1h for offline/busy, else 24h). + Cleared early if username/password change. + """ + if source is None: + return + if code not in SD_AUTH_LOCKOUT_CODES: + return + + if username is None: + username = source.username + if password is None: + password = source.password + + if reason is None: + reason = sd_auth_failure_message(code) + + seconds = sd_auth_lockout_seconds_for_code(code) + until = timezone.now() + timedelta(seconds=seconds) + cp = dict(source.custom_properties or {}) + cp['sd_auth_lockout'] = True + cp['sd_auth_lockout_code'] = code + cp['sd_auth_lockout_reason'] = reason + cp['sd_auth_lockout_until'] = until.isoformat() + cp['sd_auth_lockout_credential_fp'] = sd_credential_fingerprint( + username, password + ) + source.custom_properties = cp + source.save(update_fields=['custom_properties']) + sd_clear_cached_token(source.id) + logger.warning( + "SD source %s: auth lockout (code %s). Not calling /token until " + "username or password changes, or after %s.", + source.id, + code, + until.isoformat(), + ) + + +def sd_clear_auth_lockout(source): + """Clear a persisted auth lockout (success, credentials changed, or expired).""" + if source is None: + return + cp = dict(source.custom_properties or {}) + changed = False + for key in ( + 'sd_auth_lockout', + 'sd_auth_lockout_code', + 'sd_auth_lockout_reason', + 'sd_auth_lockout_until', + 'sd_auth_lockout_credential_fp', + ): + if key in cp: + cp.pop(key, None) + changed = True + if changed: + source.custom_properties = cp + source.save(update_fields=['custom_properties']) + + +@dataclass(frozen=True) +class SDTokenAuthResult: + """Outcome of POST /token (or a short-circuit from a persisted lockout).""" + + ok: bool + token: str | None = None + token_expires: float | None = None + code: int | None = None + message: str = '' + soft: bool = False + debug_rejected: bool = False + lockout: bool = False + + +_DEBUG_REJECTED_MESSAGE = ( + "Schedules Direct rejected the debug routing header (code 2055). " + "Extra Schedules Direct Debugging has been turned off. Retry without it " + "unless Schedules Direct support asked you to enable it." +) + + +def sd_obtain_token(source, username=None, password=None, *, timeout=30): + """ + Authenticate with Schedules Direct and return a session token. + + Shared by refresh, lineup/form auth, and the poster proxy. Honors persisted + lockouts, reads JSON ``code`` before HTTP status, and persists cooldowns for + codes that must not be retried until the user or cooldown clears them. + """ + if source is None: + return SDTokenAuthResult( + ok=False, + message='Schedules Direct source is required.', + ) + + if username is None: + username = (source.username or '').strip() + else: + username = (username or '').strip() + if password is None: + password = (source.password or '').strip() + else: + password = (password or '').strip() + + if not username or not password: + return SDTokenAuthResult( + ok=False, + message='Username and password are required.', + ) + + active, reason, lockout_code = sd_auth_lockout_active( + source, username, password + ) + if active: + msg = f"{reason} {sd_auth_lockout_retry_message()}" + return SDTokenAuthResult( + ok=False, + code=lockout_code, + message=msg, + soft=lockout_code in SD_AUTH_SOFT_CODES if lockout_code else False, + lockout=True, + ) + + sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest() + try: + response = requests.post( + f"{SD_BASE_URL}/token", + json={'username': username, 'password': sha1_password}, + headers=sd_headers_for_source(source), + timeout=timeout, + ) + except requests.exceptions.RequestException as exc: + return SDTokenAuthResult( + ok=False, + message=f'Network error authenticating with Schedules Direct: {exc}', + ) + + try: + auth_data = response.json() + except ValueError: + auth_data = {} + + if sd_handle_2055(source, auth_data): + return SDTokenAuthResult( + ok=False, + code=SD_CODE_INVALID_DEBUG, + message=_DEBUG_REJECTED_MESSAGE, + debug_rejected=True, + ) + + # Honor JSON ``code`` before raise_for_status. SD error semantics are in + # the body; HTTP status alone is not reliable for auth failures. + auth_code = sd_token_response_code(auth_data) + if auth_code != 0: + msg = sd_auth_failure_message( + auth_code, auth_data.get('message', 'Unknown error') + ) + soft = auth_code in SD_AUTH_SOFT_CODES + locked = False + if auth_code in SD_AUTH_LOCKOUT_CODES: + sd_save_auth_lockout( + source, auth_code, username, password, reason=msg + ) + msg = f"{msg} {sd_auth_lockout_retry_message()}" + locked = True + return SDTokenAuthResult( + ok=False, + code=auth_code, + message=msg, + soft=soft, + lockout=locked, + ) + + try: + response.raise_for_status() + except requests.exceptions.RequestException as exc: + return SDTokenAuthResult( + ok=False, + message=f'Network error authenticating with Schedules Direct: {exc}', + ) + + token = auth_data.get('token') if isinstance(auth_data, dict) else None + if not token: + return SDTokenAuthResult( + ok=False, + message='Schedules Direct returned no token.', + ) + + sd_clear_auth_lockout(source) + expires = None + if isinstance(auth_data, dict): + expires = auth_data.get('tokenExpires') + if not isinstance(expires, (int, float)): + expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS + return SDTokenAuthResult( + ok=True, + token=token, + token_expires=float(expires), + code=0, + ) + + def sd_next_midnight_utc(): """Return the next Schedules Direct counter reset (00:00Z).""" now = timezone.now() diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 8f1b2ef8..27c3aad9 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -195,7 +195,8 @@ def _ensure_epg_refresh_terminal_status(source_id): ) -SD_BASE_URL = 'https://json.schedulesdirect.org/20141201' +from apps.epg.sd_utils import SD_BASE_URL + SD_DAYS_TO_FETCH = 20 SD_PROGRAM_BATCH_SIZE = 5000 SD_BULK_GUIDE_FETCH_THRESHOLD = 3 @@ -3038,7 +3039,7 @@ def fetch_schedules_direct( # SD API spec requires the User-Agent to identify the application and version. # SergeantPanda confirmed Dispatcharr should identify itself properly. # ------------------------------------------------------------------------- - from apps.epg.sd_utils import sd_handle_2055, sd_headers_for_source + from apps.epg.sd_utils import sd_headers_for_source, sd_obtain_token def _sd_headers(token=None, content_type='application/json'): return sd_headers_for_source(source, token=token, content_type=content_type) @@ -3215,108 +3216,39 @@ def fetch_schedules_direct( source.save(update_fields=['status', 'last_message']) send_epg_update(source.id, "parsing_programs", 2, message="Authenticating with Schedules Direct...") - try: - sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest() - token_response = requests.post( - f"{SD_BASE_URL}/token", - json={'username': username, 'password': sha1_password}, - headers=_sd_headers(), - timeout=30, - ) - try: - token_data = token_response.json() - except ValueError: - token_data = {} - - if sd_handle_2055(source, token_data): - msg = ( - "Schedules Direct rejected the debug routing header (code 2055). " - "Extra Schedules Direct Debugging has been turned off. Retry the " - "refresh unless Schedules Direct support asked you to enable it." - ) - logger.error(msg) - source.status = EPGSource.STATUS_ERROR - source.last_message = msg - source.save(update_fields=['status', 'last_message']) - send_epg_update(source.id, "refresh", 100, status="error", error=msg) - return - - token_response.raise_for_status() - - auth_code = token_data.get('code', 0) - if auth_code != 0: - # Soft offline/busy: stop without treating as a credential error. - if auth_code in (3000, 3001): - if auth_code == 3000: - msg = ( - "Schedules Direct is offline for maintenance. " - "Do not retry for at least 1 hour." - ) - else: - msg = ( - "Schedules Direct is busy. Stop requesting and retry later." - ) - logger.warning(msg) - source.status = EPGSource.STATUS_IDLE - source.last_message = msg - source.save(update_fields=['status', 'last_message']) - send_epg_update(source.id, "refresh", 100, status="idle", message=msg) - return - - if auth_code == 4007: - msg = "Schedules Direct: this application is not authorized. Please contact the Dispatcharr maintainers." - elif auth_code == 4004: - msg = "Schedules Direct: account locked due to too many failed login attempts. Try again in 15 minutes." - elif auth_code == 4009: - msg = "Schedules Direct: too many login attempts in 24 hours. Token is valid for 24 hours. Check for misconfiguration." - elif auth_code == 4001: - msg = "Schedules Direct: account has expired. Please renew your subscription at schedulesdirect.org." - elif auth_code == 4008: - msg = "Schedules Direct: account is inactive. Please log in to schedulesdirect.org to reactivate." - elif auth_code == 4002: - msg = "Schedules Direct: invalid password hash. Check that credentials are stored correctly." - elif auth_code == 4003: - msg = "Schedules Direct: invalid username or password." - elif auth_code == 4005: - msg = ( - "Schedules Direct: JSON API access is disabled for this account. " - "Contact Schedules Direct support." - ) - elif auth_code == 4010: - msg = ( - "Schedules Direct: too many unique IP addresses in 24 hours. " - "Avoid VPNs or multiple locations, or contact SD support to raise the limit." - ) - else: - msg = f"Schedules Direct authentication failed (code {auth_code}): {token_data.get('message', 'Unknown error')}" - logger.error(msg) - source.status = EPGSource.STATUS_ERROR - source.last_message = msg - source.save(update_fields=['status', 'last_message']) - send_epg_update(source.id, "refresh", 100, status="error", error=msg) - return - - token = token_data.get('token') - if not token: - msg = "Schedules Direct returned no token." - logger.error(msg) - source.status = EPGSource.STATUS_ERROR - source.last_message = msg - source.save(update_fields=['status', 'last_message']) - send_epg_update(source.id, "refresh", 100, status="error", error=msg) - return - - logger.info("Schedules Direct authentication successful.") - - except requests.exceptions.RequestException as e: - msg = f"Network error authenticating with Schedules Direct: {e}" - logger.error(msg, exc_info=True) + auth = sd_obtain_token(source, username, password, timeout=30) + if auth.debug_rejected: + logger.error(auth.message) source.status = EPGSource.STATUS_ERROR - source.last_message = msg + source.last_message = auth.message source.save(update_fields=['status', 'last_message']) - send_epg_update(source.id, "refresh", 100, status="error", error=msg) + send_epg_update( + source.id, "refresh", 100, status="error", error=auth.message + ) return + if not auth.ok: + if auth.soft: + logger.warning(auth.message) + source.status = EPGSource.STATUS_IDLE + source.last_message = auth.message + source.save(update_fields=['status', 'last_message']) + send_epg_update( + source.id, "refresh", 100, status="idle", message=auth.message + ) + return + logger.error(auth.message) + source.status = EPGSource.STATUS_ERROR + source.last_message = auth.message + source.save(update_fields=['status', 'last_message']) + send_epg_update( + source.id, "refresh", 100, status="error", error=auth.message + ) + return + + token = auth.token + logger.info("Schedules Direct authentication successful.") + # ------------------------------------------------------------------------- # Step 2: Check account status (respect OFFLINE system status) # ------------------------------------------------------------------------- diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index 044df3e9..b476940f 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -226,6 +226,152 @@ class SDLineupChangesRemainingTests(TestCase): ) +class SDAuthCredentialLockoutTests(TestCase): + """4002/4003 lockouts must stop /token until credentials change or 24h.""" + + def test_lockout_active_until_password_changes(self): + from apps.epg.sd_utils import ( + sd_auth_lockout_active, + sd_save_auth_lockout, + ) + + source = EPGSource.objects.create( + name='SD Cred Lockout Utils', + source_type='schedules_direct', + username='u', + password='old', + ) + sd_save_auth_lockout(source, 4003, 'u', 'old') + active, reason, code = sd_auth_lockout_active(source, 'u', 'old') + self.assertTrue(active) + self.assertEqual(code, 4003) + self.assertIn('invalid username or password', reason.lower()) + + active, reason, code = sd_auth_lockout_active(source, 'u', 'new') + self.assertFalse(active) + source.refresh_from_db() + self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout')) + + def test_lockout_clears_when_username_changes(self): + """4003 is username or password; either field change must allow retry.""" + from apps.epg.sd_utils import ( + sd_auth_lockout_active, + sd_save_auth_lockout, + ) + + source = EPGSource.objects.create( + name='SD Cred Lockout Username', + source_type='schedules_direct', + username='olduser', + password='samepass', + ) + sd_save_auth_lockout(source, 4003, 'olduser', 'samepass') + self.assertTrue(sd_auth_lockout_active(source, 'olduser', 'samepass')[0]) + + active, _, _ = sd_auth_lockout_active(source, 'newuser', 'samepass') + self.assertFalse(active) + source.refresh_from_db() + self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout')) + + def test_lockout_expires_after_24_hours(self): + """Auto-clear after 24h so a transient SD-side failure can retry.""" + from apps.epg.sd_utils import ( + sd_auth_lockout_active, + sd_save_auth_lockout, + ) + + source = EPGSource.objects.create( + name='SD Cred Lockout Expiry', + source_type='schedules_direct', + username='u', + password='p', + ) + frozen_now = timezone.now() + with patch('apps.epg.sd_utils.timezone.now', return_value=frozen_now): + sd_save_auth_lockout(source, 4003, 'u', 'p') + self.assertTrue(sd_auth_lockout_active(source, 'u', 'p')[0]) + source.refresh_from_db() + self.assertTrue( + (source.custom_properties or {}).get('sd_auth_lockout_until') + ) + + later = frozen_now + timedelta(hours=24, seconds=1) + with patch('apps.epg.sd_utils.timezone.now', return_value=later): + active, _, _ = sd_auth_lockout_active(source, 'u', 'p') + self.assertFalse(active) + source.refresh_from_db() + self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout')) + + def test_account_expired_4001_locks_for_24_hours(self): + from django.utils.dateparse import parse_datetime + + from apps.epg.sd_utils import ( + SD_AUTH_LOCKOUT_SECONDS, + sd_auth_lockout_seconds_for_code, + sd_save_auth_lockout, + ) + + self.assertEqual(sd_auth_lockout_seconds_for_code(4001), SD_AUTH_LOCKOUT_SECONDS) + self.assertEqual(sd_auth_lockout_seconds_for_code(4004), 15 * 60) + self.assertEqual(sd_auth_lockout_seconds_for_code(3000), 3600) + + source = EPGSource.objects.create( + name='SD Expired Lockout', + source_type='schedules_direct', + username='u', + password='p', + ) + frozen_now = timezone.now() + with patch('apps.epg.sd_utils.timezone.now', return_value=frozen_now): + sd_save_auth_lockout(source, 4001, 'u', 'p') + source.refresh_from_db() + self.assertEqual(source.custom_properties.get('sd_auth_lockout_code'), 4001) + until = parse_datetime(source.custom_properties['sd_auth_lockout_until']) + self.assertAlmostEqual( + (until - frozen_now).total_seconds(), + 24 * 3600, + delta=2, + ) + + @patch('requests.post') + def test_sd_authenticate_http_400_with_4003_locks_out(self, mock_post): + """Lineup/form auth must persist lockout on HTTP 400 + code 4003.""" + import requests + from apps.epg.api_views import EPGSourceViewSet + + mock_resp = MagicMock( + status_code=400, + json=MagicMock(return_value={ + 'code': 4003, + 'message': 'Invalid user or password', + }), + ) + mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError( + '400 Client Error', response=mock_resp + ) + mock_post.return_value = mock_resp + + source = EPGSource.objects.create( + name='SD Form Auth Fail', + source_type='schedules_direct', + username='baduser', + password='badpass', + ) + view = EPGSourceViewSet() + token, error = view._sd_authenticate(source) + self.assertIsNone(token) + self.assertEqual(error.status_code, 401) + self.assertIn('invalid username or password', error.data['error'].lower()) + source.refresh_from_db() + self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout')) + + # Second call must not hit /token. + mock_post.reset_mock() + token, error = view._sd_authenticate(source) + self.assertIsNone(token) + mock_post.assert_not_called() + + class FetchSchedulesDirectAuthCodeTests(TestCase): """Token response codes must map to idle vs error correctly.""" @@ -280,6 +426,90 @@ class FetchSchedulesDirectAuthCodeTests(TestCase): source.refresh_from_db() self.assertEqual(source.status, EPGSource.STATUS_ERROR) self.assertIn('invalid username or password', source.last_message.lower()) + self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout')) + + @patch('apps.epg.tasks.requests.post') + def test_auth_4003_http_400_persists_lockout_without_raise(self, mock_post): + """HTTP 400 + JSON code 4003 must hard-stop before raise_for_status.""" + import requests + + mock_resp = MagicMock( + status_code=400, + json=MagicMock(return_value={ + 'code': 4003, + 'message': 'Invalid user or password', + }), + ) + mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError( + '400 Client Error', response=mock_resp + ) + mock_post.return_value = mock_resp + + from apps.epg.tasks import fetch_schedules_direct + source = EPGSource.objects.create( + name='SD Auth HTTP 400', + source_type='schedules_direct', + username='baduser', + password='badpass', + ) + + with patch('apps.epg.tasks.send_epg_update'): + fetch_schedules_direct(source) + + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + self.assertIn('invalid username or password', source.last_message.lower()) + self.assertTrue((source.custom_properties or {}).get('sd_auth_lockout')) + mock_resp.raise_for_status.assert_not_called() + + @patch('apps.epg.tasks.requests.post') + def test_auth_lockout_skips_token_until_credentials_change(self, mock_post): + """Persisted 4003 lockout must not call /token again until credentials change.""" + from apps.epg.sd_utils import sd_save_auth_lockout + from apps.epg.tasks import fetch_schedules_direct + + source = EPGSource.objects.create( + name='SD Auth Lockout', + source_type='schedules_direct', + username='baduser', + password='badpass', + ) + sd_save_auth_lockout(source, 4003, 'baduser', 'badpass') + + with patch('apps.epg.tasks.send_epg_update'): + fetch_schedules_direct(source) + + mock_post.assert_not_called() + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + self.assertIn('not retrying', source.last_message.lower()) + + # Changing credentials clears the fingerprint lockout and allows /token. + source.password = 'newpass' + source.save(update_fields=['password']) + mock_post.return_value = MagicMock( + status_code=200, + json=MagicMock(return_value={ + 'code': 0, + 'token': 'new-token', + 'tokenExpires': time.time() + 86400, + }), + raise_for_status=MagicMock(), + ) + with patch('apps.epg.tasks.send_epg_update'), \ + patch('apps.epg.tasks.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=MagicMock(return_value={ + 'systemStatus': [{'status': 'Offline'}], + }), + raise_for_status=MagicMock(), + ) + fetch_schedules_direct(source) + + mock_post.assert_called_once() + source.refresh_from_db() + self.assertFalse((source.custom_properties or {}).get('sd_auth_lockout')) @patch('apps.epg.tasks.requests.post') def test_auth_too_many_ips_sets_error_status(self, mock_post):