feat(epg): Enhance Schedules Direct authentication with lockout handling and refactor token retrieval

This commit improves the Schedules Direct authentication process by implementing a lockout mechanism for specific error codes (4002/4003), preventing repeated token requests during lockout periods. The token retrieval logic has been centralized in the `sd_obtain_token` function, streamlining the authentication process across various components. Additionally, comprehensive tests have been added to ensure the correct handling of lockouts and authentication failures, enhancing overall reliability and user experience.
This commit is contained in:
SergeantPanda 2026-07-21 14:35:04 +00:00
parent dc59469b21
commit ddef24a8fc
5 changed files with 698 additions and 184 deletions

View file

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

View file

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

View file

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

View file

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

View file

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