Enhance Schedules Direct integration with per-channel guide fetch and improved EPG handling

- Implemented a targeted guide fetch for Schedules Direct when mapping a channel, ensuring immediate data availability without redundant API calls.
- Updated EPG signals to queue guide fetches for newly mapped channels, streamlining the process and improving user experience.
- Refactored existing logic to skip unnecessary program refreshes for dummy sources and ensure proper handling of Schedules Direct data.
- Added unit tests to validate the new fetching behavior and ensure robustness of the integration.
This commit is contained in:
SergeantPanda 2026-06-11 13:54:19 -05:00
parent bbff67899f
commit c274bd7fbb
6 changed files with 466 additions and 210 deletions

View file

@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Schedules Direct lineup search country dropdown.** The country list was fetched directly from the SD API in the browser, which failed due to CORS and silently fell back to a 14-country hardcoded list. Countries are now included in the `GET sd-lineups` response (server-side fetch with proper User-Agent) so the full SD country list populates correctly. — Thanks [@sethwv](https://github.com/sethwv)
- **Schedules Direct program poster proxy omitted User-Agent on image requests.** The poster endpoint authenticated with SD correctly but fetched images with only the `token` header, which violates SD's API requirements (error 1003). Image requests now include the standard `Dispatcharr/{version}` User-Agent via `dispatcharr_http_headers()`.
- **Schedules Direct guide data missing after mapping a channel.** Lineup refreshes cached schedule MD5s for all stations, but `ProgramData` was only written for mapped channels. Mapping a channel later could leave MD5s looking unchanged so schedules were skipped and the channel had no guide until dates rolled outside the cached window. Refreshes now backfill fetch-window dates that lack `ProgramData` (including newly mapped stations with stale cache), fetch program metadata when no local `ProgramData` exists for a `programID`, and clean up orphaned `ProgramData` on unmapped `EPGData` entries. — Thanks [@sethwv](https://github.com/sethwv)
- **Schedules Direct guide fetch on channel map.** Mapping a channel (or bulk-assigning EPG) now triggers a targeted guide fetch for that `EPGData` entry—mirroring XMLTV's `parse_programs_for_tvg_id` flow—without waiting for the next full source refresh. Skips the fetch when `ProgramData` already exists so additional channels sharing the same `tvg-id` do not trigger redundant API calls.
## [0.26.0] - 2026-06-07

View file

@ -2163,22 +2163,14 @@ class ChannelViewSet(viewsets.ModelViewSet):
epg_data = EPGData.objects.get(pk=epg_data_id)
# Set the EPG data and save
# Set the EPG data and save. refresh_epg_programs (post_save) queues
# parse_programs_for_tvg_id for non-dummy sources — no second dispatch here.
channel.epg_data = epg_data
channel.save(update_fields=["epg_data"])
# Only trigger program refresh for non-dummy EPG sources
status_message = None
if epg_data.epg_source.source_type != 'dummy':
# Explicitly trigger program refresh for this EPG
from apps.epg.tasks import parse_programs_for_tvg_id
task_result = parse_programs_for_tvg_id.delay(epg_data.id)
# Prepare response with task status info
status_message = "EPG refresh queued"
if task_result.result == "Task already running":
status_message = "EPG refresh already in progress"
# Build response message
message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}"
@ -2374,7 +2366,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
continue
source_type = epg_data.epg_source.source_type if epg_data.epg_source else None
if source_type not in ('dummy', 'schedules_direct'):
if source_type != 'dummy':
parse_programs_for_tvg_id.delay(epg_id)
programs_refreshed += 1

View file

@ -327,7 +327,7 @@ def _dispatch_program_parse_for_epg_assignments(changed_associations):
dispatched = 0
for epg in EPGData.objects.filter(id__in=epg_ids).select_related("epg_source"):
source_type = epg.epg_source.source_type if epg.epg_source else None
if source_type in ("dummy", "schedules_direct"):
if source_type == "dummy":
continue
parse_programs_for_tvg_id.delay(epg.id)
dispatched += 1

View file

@ -201,18 +201,13 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
if instance.epg_data:
# Skip XMLTV program parser for SD sources — program data is handled
# by fetch_schedules_direct() directly.
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
return
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
parse_programs_for_tvg_id.delay(instance.epg_data.id)
# For new channels with EPG data, also refresh
elif created and instance.epg_data:
# Skip XMLTV program parser for SD sources
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
return
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
parse_programs_for_tvg_id.delay(instance.epg_data.id)

View file

@ -1496,14 +1496,11 @@ def parse_channels_only(source):
@shared_task(time_limit=3600, soft_time_limit=3500)
def parse_programs_for_tvg_id(epg_id, force=False):
# Skip XMLTV file parsing for Schedules Direct sources. Program data is
# fetched and persisted directly by fetch_schedules_direct().
try:
from apps.epg.models import EPGData
epg_obj = EPGData.objects.select_related('epg_source').filter(id=epg_id).first()
if epg_obj and epg_obj.epg_source and epg_obj.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD EPGData id={epg_id} (source: {epg_obj.epg_source.name})")
return "Skipped (Schedules Direct source)"
return fetch_sd_guide_for_epg(epg_id, force=force)
except Exception as e:
logger.warning(f"Could not check EPG source type for id={epg_id}: {e}")
@ -2139,6 +2136,73 @@ def parse_programs_for_source(epg_source, tvg_id=None):
process = None
def _sd_setup_single_epg_fetch(source, epg_id, token, sd_headers_fn):
"""Build station_map / epg_id_map for a single mapped EPG entry."""
epg = EPGData.objects.filter(id=epg_id, epg_source=source).first()
if not epg or not epg.tvg_id:
msg = f"Schedules Direct EPG entry {epg_id} not found or missing station ID."
logger.error(msg)
source.last_message = msg
source.save(update_fields=['last_message'])
send_epg_update(source.id, "parsing_programs", 100, status="error", error=msg)
return None
sd_lineup_country = None
try:
lineups_response = requests.get(
f"{SD_BASE_URL}/lineups",
headers=sd_headers_fn(token),
timeout=30,
)
if lineups_response.ok:
for lineup in lineups_response.json().get('lineups', []):
lid = lineup.get('lineupID') or lineup.get('lineup') or ''
if '-' in lid:
sd_lineup_country = lid.split('-')[0]
break
except requests.exceptions.RequestException as e:
logger.warning(f"Could not fetch lineups for country code: {e}")
send_epg_update(
source.id, "parsing_programs", 15,
message=f"Fetching guide data for {epg.name or epg.tvg_id}...",
)
station_map = {epg.tvg_id: {'name': epg.name or epg.tvg_id}}
epg_id_map = {epg.tvg_id: epg.id}
return station_map, epg_id_map, sd_lineup_country, epg
@shared_task(time_limit=3600, soft_time_limit=3500)
def fetch_sd_guide_for_epg(epg_id, force=False):
"""
Fetch Schedules Direct guide data for one mapped EPG entry (channel map flow).
Skips when ProgramData already exists so additional channels sharing the
same EPGData / tvg_id do not trigger redundant API calls.
"""
epg = EPGData.objects.select_related('epg_source').filter(id=epg_id).first()
if not epg or not epg.epg_source or epg.epg_source.source_type != 'schedules_direct':
return "Not a Schedules Direct EPG entry"
if not force and ProgramData.objects.filter(epg_id=epg_id).exists():
logger.info(f"SD guide fetch skipped for EPG {epg_id}: ProgramData already present")
return "Guide data already present"
if not acquire_task_lock('parse_epg_programs', epg_id):
logger.info(f"SD guide fetch for EPG {epg_id} already in progress, skipping duplicate task")
return "Task already running"
lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id)
lock_renewer.start()
try:
logger.info(f"Fetching Schedules Direct guide for EPG {epg_id} ({epg.tvg_id})")
fetch_schedules_direct(epg.epg_source, epg_id_only=epg_id, force=force)
return "SD guide fetch complete"
finally:
lock_renewer.stop()
release_task_lock('parse_epg_programs', epg_id)
@shared_task(bind=True)
def fetch_schedules_direct_stations(self, source_id):
"""
@ -2154,7 +2218,7 @@ def fetch_schedules_direct_stations(self, source_id):
fetch_schedules_direct(source, stations_only=True)
def fetch_schedules_direct(source, stations_only=False, force=False):
def fetch_schedules_direct(source, stations_only=False, force=False, epg_id_only=None):
"""
Fetch EPG data from the Schedules Direct JSON API and persist it to the
EPGData / ProgramData models.
@ -2185,8 +2249,15 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
import hashlib
from datetime import date
single_epg_fetch = epg_id_only is not None
logger.info(f"Fetching Schedules Direct data for source: {source.name}")
if single_epg_fetch:
logger.info(
f"Fetching Schedules Direct guide for EPG {epg_id_only} "
f"(source: {source.name})"
)
else:
logger.info(f"Fetching Schedules Direct data for source: {source.name}")
# -------------------------------------------------------------------------
# Validate credentials
@ -2213,7 +2284,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
# updated_at, which would otherwise incorrectly trigger this guard). Always
# allow the first full refresh through so guide data is immediately available.
# -------------------------------------------------------------------------
if not stations_only and not force and source.updated_at:
if not stations_only and not force and not single_epg_fetch and source.updated_at:
from apps.epg.models import SDScheduleMD5 as _SDScheduleMD5
has_prior_full_refresh = _SDScheduleMD5.objects.filter(epg_source=source).exists()
if has_prior_full_refresh:
@ -2234,7 +2305,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
return
else:
logger.info(f"SD source {source.id}: No prior full refresh detected, skipping 2-hour guard for first full fetch.")
elif force and not stations_only:
elif force and not stations_only and not single_epg_fetch:
logger.info(f"SD source {source.id}: Force flag set, bypassing 2-hour refresh guard.")
# -------------------------------------------------------------------------
@ -2406,9 +2477,10 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
# This is a requirement of the Schedules Direct API specification, not an
# architectural choice.
# -------------------------------------------------------------------------
source.status = EPGSource.STATUS_FETCHING
source.last_message = "Authenticating with Schedules Direct..."
source.save(update_fields=['status', 'last_message'])
if not single_epg_fetch:
source.status = EPGSource.STATUS_FETCHING
source.last_message = "Authenticating with Schedules Direct..."
source.save(update_fields=['status', 'last_message'])
send_epg_update(source.id, "parsing_programs", 2, message="Authenticating with Schedules Direct...")
try:
@ -2491,191 +2563,201 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
except requests.exceptions.RequestException as e:
logger.warning(f"Could not fetch SD system status, proceeding anyway: {e}")
# -------------------------------------------------------------------------
# Step 3: Fetch subscribed lineups and build station map
# -------------------------------------------------------------------------
send_epg_update(source.id, "parsing_programs", 10, message="Fetching subscribed lineups...")
try:
lineups_response = requests.get(
f"{SD_BASE_URL}/lineups",
headers=_sd_headers(token),
timeout=30,
)
# SD returns 400 with code 4102 when no lineups are configured.
# This is a valid account state. The user needs to add lineups via
# the Manage Lineups UI. Treat as idle rather than error.
if lineups_response.status_code == 400:
sd_data = lineups_response.json()
if sd_data.get('code') == 4102:
station_map = None
epg_id_map = None
sd_lineup_country = None
if epg_id_only is not None:
setup = _sd_setup_single_epg_fetch(source, epg_id_only, token, _sd_headers)
if setup is None:
return
station_map, epg_id_map, sd_lineup_country, _single_epg = setup
else:
# -------------------------------------------------------------------------
# Step 3: Fetch subscribed lineups and build station map
# -------------------------------------------------------------------------
send_epg_update(source.id, "parsing_programs", 10, message="Fetching subscribed lineups...")
try:
lineups_response = requests.get(
f"{SD_BASE_URL}/lineups",
headers=_sd_headers(token),
timeout=30,
)
# SD returns 400 with code 4102 when no lineups are configured.
# This is a valid account state. The user needs to add lineups via
# the Manage Lineups UI. Treat as idle rather than error.
if lineups_response.status_code == 400:
sd_data = lineups_response.json()
if sd_data.get('code') == 4102:
msg = "No lineups configured. Use the Manage Lineups option in the EPG source settings to add a lineup."
logger.warning(f"SD source {source.id}: no lineups configured on account (4102).")
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
lineups_response.raise_for_status()
lineups_data = lineups_response.json()
lineups = [l for l in lineups_data.get('lineups', []) if not l.get('isDeleted', False)]
if not lineups:
msg = "No lineups configured. Use the Manage Lineups option in the EPG source settings to add a lineup."
logger.warning(f"SD source {source.id}: no lineups configured on account (4102).")
logger.warning(f"SD source {source.id}: no active lineups found.")
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
lineups_response.raise_for_status()
lineups_data = lineups_response.json()
lineups = [l for l in lineups_data.get('lineups', []) if not l.get('isDeleted', False)]
if not lineups:
msg = "No lineups configured. Use the Manage Lineups option in the EPG source settings to add a lineup."
logger.warning(f"SD source {source.id}: no active lineups found.")
source.status = EPGSource.STATUS_IDLE
logger.info(f"Found {len(lineups)} lineup(s) in SD account.")
# Extract country from lineup IDs (format: "USA-NJ29486-X", "GBR-...", etc.)
sd_lineup_country = None
for l in lineups:
lid = l.get('lineupID') or l.get('lineup') or ''
if '-' in lid:
sd_lineup_country = lid.split('-')[0]
break
logger.debug(f"SD lineup country: {sd_lineup_country}")
except requests.exceptions.RequestException as e:
msg = f"Failed to fetch Schedules Direct lineups: {e}"
logger.error(msg, exc_info=True)
source.status = EPGSource.STATUS_ERROR
source.last_message = msg
source.save(update_fields=['status', 'last_message'])
send_epg_update(source.id, "refresh", 100, status="idle", message=msg)
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
return
logger.info(f"Found {len(lineups)} lineup(s) in SD account.")
# Extract country from lineup IDs (format: "USA-NJ29486-X", "GBR-...", etc.)
sd_lineup_country = None
for l in lineups:
lid = l.get('lineupID') or l.get('lineup') or ''
if '-' in lid:
sd_lineup_country = lid.split('-')[0]
break
logger.debug(f"SD lineup country: {sd_lineup_country}")
except requests.exceptions.RequestException as e:
msg = f"Failed to fetch Schedules Direct lineups: {e}"
logger.error(msg, exc_info=True)
source.status = EPGSource.STATUS_ERROR
source.last_message = msg
# Build station metadata map: stationID -> {name, callsign, logo_url}
station_map = {}
send_epg_update(source.id, "parsing_programs", 18, message=f"Fetching station metadata for {len(lineups)} lineup(s)...")
for lineup in lineups:
lineup_id = lineup.get('lineupID') or lineup.get('lineup')
if not lineup_id:
continue
try:
detail_response = requests.get(
f"{SD_BASE_URL}/lineups/{lineup_id}",
headers=_sd_headers(token),
timeout=30,
)
detail_response.raise_for_status()
detail_data = detail_response.json()
for station in detail_data.get('stations', []):
sid = station.get('stationID')
if not sid:
continue
logo_url = None
logos = station.get('stationLogo') or station.get('logo') or []
if isinstance(logos, list) and logos:
# Read preferred logo style from source settings; default to 'dark'
logo_style = (source.custom_properties or {}).get('logo_style', 'dark')
preferred = next((l for l in logos if l.get('category') == logo_style), logos[0])
logo_url = preferred.get('URL') or preferred.get('url')
elif isinstance(logos, dict):
logo_url = logos.get('URL') or logos.get('url')
station_map[sid] = {
'name': station.get('name', sid),
'callsign': station.get('callsign', ''),
'logo_url': logo_url,
}
logger.debug(f"Fetched {len(detail_data.get('stations', []))} stations from lineup {lineup_id}")
except requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch lineup details for {lineup_id}: {e}")
if not station_map:
msg = "No stations found across all Schedules Direct lineups."
logger.warning(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(f"Built station map with {len(station_map)} stations.")
# -------------------------------------------------------------------------
# Step 4: Persist station metadata to EPGData
# -------------------------------------------------------------------------
source.status = EPGSource.STATUS_PARSING
source.last_message = f"Syncing {len(station_map)} stations..."
source.save(update_fields=['status', 'last_message'])
send_epg_update(source.id, "refresh", 100, status="error", error=msg)
return
# Build station metadata map: stationID -> {name, callsign, logo_url}
station_map = {}
send_epg_update(source.id, "parsing_programs", 18, message=f"Fetching station metadata for {len(lineups)} lineup(s)...")
for lineup in lineups:
lineup_id = lineup.get('lineupID') or lineup.get('lineup')
if not lineup_id:
continue
try:
detail_response = requests.get(
f"{SD_BASE_URL}/lineups/{lineup_id}",
headers=_sd_headers(token),
timeout=30,
send_epg_update(source.id, "parsing_programs", 28, message=f"Syncing {len(station_map)} stations to database...")
existing_epg_map = {
epg.tvg_id: epg
for epg in EPGData.objects.filter(epg_source=source)
}
epgs_to_create = []
epgs_to_update = []
icon_max_length = EPGData._meta.get_field('icon_url').max_length
name_max_length = EPGData._meta.get_field('name').max_length
for sid, info in station_map.items():
display_name = (info['name'] or sid)[:name_max_length]
logo = info['logo_url']
if logo and len(logo) > icon_max_length:
logo = None
if sid in existing_epg_map:
epg_obj = existing_epg_map[sid]
needs_update = False
if epg_obj.name != display_name:
epg_obj.name = display_name
needs_update = True
if epg_obj.icon_url != logo:
epg_obj.icon_url = logo
needs_update = True
if needs_update:
epgs_to_update.append(epg_obj)
else:
epgs_to_create.append(EPGData(
tvg_id=sid,
name=display_name,
icon_url=logo,
epg_source=source,
))
if epgs_to_create:
EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True)
logger.info(f"Created {len(epgs_to_create)} new EPGData entries.")
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ['name', 'icon_url'])
logger.info(f"Updated {len(epgs_to_update)} existing EPGData entries.")
gc.collect()
# Rebuild map with fresh DB ids for all stations
epg_id_map = {
epg.tvg_id: epg.id
for epg in EPGData.objects.filter(epg_source=source, tvg_id__in=list(station_map.keys()))
}
# Station sync complete. Send progress update before continuing into programs phase.
# We deliberately do NOT send parsing_channels at 100 with status=success here
# because that would cause the frontend to mark the source as complete and
# stop rendering progress updates for the subsequent program fetch phases.
send_epg_update(source.id, "parsing_programs", 30,
message=f"Stations synced ({len(station_map)} stations). Preparing schedule fetch...")
# -------------------------------------------------------------------------
# Stations-only mode. Used on initial source creation.
# Stop here so the user can run Auto-match EPG before the full program fetch.
# -------------------------------------------------------------------------
if stations_only:
success_msg = (
f"{len(station_map)} stations loaded from Schedules Direct. "
f"Run Auto-match EPG to map your channels, then use the Refresh "
f"button to populate guide data."
)
detail_response.raise_for_status()
detail_data = detail_response.json()
for station in detail_data.get('stations', []):
sid = station.get('stationID')
if not sid:
continue
logo_url = None
logos = station.get('stationLogo') or station.get('logo') or []
if isinstance(logos, list) and logos:
# Read preferred logo style from source settings; default to 'dark'
logo_style = (source.custom_properties or {}).get('logo_style', 'dark')
preferred = next((l for l in logos if l.get('category') == logo_style), logos[0])
logo_url = preferred.get('URL') or preferred.get('url')
elif isinstance(logos, dict):
logo_url = logos.get('URL') or logos.get('url')
station_map[sid] = {
'name': station.get('name', sid),
'callsign': station.get('callsign', ''),
'logo_url': logo_url,
}
logger.debug(f"Fetched {len(detail_data.get('stations', []))} stations from lineup {lineup_id}")
except requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch lineup details for {lineup_id}: {e}")
if not station_map:
msg = "No stations found across all Schedules Direct lineups."
logger.warning(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(f"Built station map with {len(station_map)} stations.")
# -------------------------------------------------------------------------
# Step 4: Persist station metadata to EPGData
# -------------------------------------------------------------------------
source.status = EPGSource.STATUS_PARSING
source.last_message = f"Syncing {len(station_map)} stations..."
source.save(update_fields=['status', 'last_message'])
send_epg_update(source.id, "parsing_programs", 28, message=f"Syncing {len(station_map)} stations to database...")
existing_epg_map = {
epg.tvg_id: epg
for epg in EPGData.objects.filter(epg_source=source)
}
epgs_to_create = []
epgs_to_update = []
icon_max_length = EPGData._meta.get_field('icon_url').max_length
name_max_length = EPGData._meta.get_field('name').max_length
for sid, info in station_map.items():
display_name = (info['name'] or sid)[:name_max_length]
logo = info['logo_url']
if logo and len(logo) > icon_max_length:
logo = None
if sid in existing_epg_map:
epg_obj = existing_epg_map[sid]
needs_update = False
if epg_obj.name != display_name:
epg_obj.name = display_name
needs_update = True
if epg_obj.icon_url != logo:
epg_obj.icon_url = logo
needs_update = True
if needs_update:
epgs_to_update.append(epg_obj)
else:
epgs_to_create.append(EPGData(
tvg_id=sid,
name=display_name,
icon_url=logo,
epg_source=source,
))
if epgs_to_create:
EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True)
logger.info(f"Created {len(epgs_to_create)} new EPGData entries.")
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ['name', 'icon_url'])
logger.info(f"Updated {len(epgs_to_update)} existing EPGData entries.")
gc.collect()
# Rebuild map with fresh DB ids for all stations
epg_id_map = {
epg.tvg_id: epg.id
for epg in EPGData.objects.filter(epg_source=source, tvg_id__in=list(station_map.keys()))
}
# Station sync complete. Send progress update before continuing into programs phase.
# We deliberately do NOT send parsing_channels at 100 with status=success here
# because that would cause the frontend to mark the source as complete and
# stop rendering progress updates for the subsequent program fetch phases.
send_epg_update(source.id, "parsing_programs", 30,
message=f"Stations synced ({len(station_map)} stations). Preparing schedule fetch...")
# -------------------------------------------------------------------------
# Stations-only mode. Used on initial source creation.
# Stop here so the user can run Auto-match EPG before the full program fetch.
# -------------------------------------------------------------------------
if stations_only:
success_msg = (
f"{len(station_map)} stations loaded from Schedules Direct. "
f"Run Auto-match EPG to map your channels, then use the Refresh "
f"button to populate guide data."
)
source.status = EPGSource.STATUS_SUCCESS
source.last_message = success_msg
source.updated_at = timezone.now()
source.save(update_fields=['status', 'last_message', 'updated_at'])
send_epg_update(source.id, "parsing_channels", 100, status="success",
message=success_msg, channels_count=len(station_map))
logger.info(f"Stations-only fetch complete for source: {source.name} ({len(station_map)} stations)")
return
source.status = EPGSource.STATUS_SUCCESS
source.last_message = success_msg
source.updated_at = timezone.now()
source.save(update_fields=['status', 'last_message', 'updated_at'])
send_epg_update(source.id, "parsing_channels", 100, status="success",
message=success_msg, channels_count=len(station_map))
logger.info(f"Stations-only fetch complete for source: {source.name} ({len(station_map)} stations)")
return
# -------------------------------------------------------------------------
# Step 5: MD5-delta schedule fetch
# Only mapped channels need guide data. Fetch MD5 hashes and schedules for
@ -2720,6 +2802,12 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
if not mapped_station_ids:
logger.info("No channels mapped to this SD source; skipping schedule MD5 check and downloads.")
_sd_post_refresh_tasks(mapped_epg_ids, {}, today)
if single_epg_fetch:
msg = "No mapped channel found for this EPG entry; guide fetch skipped."
source.last_message = msg
source.save(update_fields=['last_message'])
send_epg_update(source.id, "parsing_programs", 100, status="idle", message=msg)
return
success_msg = (
f"{len(station_map)} lineup stations synced. "
"Map channels to EPG entries, then refresh to populate guide data."
@ -2828,6 +2916,12 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
if not changed_by_station:
logger.info("No schedule changes detected, skipping schedule and program downloads.")
_sd_post_refresh_tasks(mapped_epg_ids, {}, today)
if single_epg_fetch:
msg = "No schedule updates needed; guide data is up to date."
source.last_message = msg
source.save(update_fields=['last_message'])
send_epg_update(source.id, "parsing_programs", 100, status="success", message=msg)
return
send_epg_update(source.id, "parsing_programs", 100, status="success",
message="No schedule changes detected since last refresh. Guide data is up to date.")
source.status = EPGSource.STATUS_SUCCESS
@ -3383,6 +3477,25 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
# -------------------------------------------------------------------------
# Done
# -------------------------------------------------------------------------
if single_epg_fetch:
epg_label = EPGData.objects.filter(id=epg_id_only).values_list('name', flat=True).first()
success_msg = (
f"Fetched {total_programs:,} programs for "
f"{epg_label or epg_id_only} from Schedules Direct."
)
source.last_message = success_msg
source.save(update_fields=['last_message'])
send_epg_update(source.id, "parsing_programs", 100, status="success", message=success_msg)
log_system_event(
event_type='epg_refresh',
source_name=source.name,
programs=total_programs,
channels=1,
skipped_programs=skipped_unmapped,
)
logger.info(f"Schedules Direct single-EPG fetch complete for source: {source.name}")
return
success_msg = (
f"Successfully fetched {total_programs:,} programs for "
f"{len(mapped_tvg_ids)} mapped stations from Schedules Direct "

View file

@ -9,7 +9,8 @@ Covers:
- fetch_schedules_direct: graceful error handling on auth failure
- fetch_schedules_direct: schedule MD5 delta, backfill, and cache invalidation
- parse_schedules_direct_time: correct UTC parsing
- EPG signals: SD sources skip the XMLTV program parser
- fetch_sd_guide_for_epg: per-channel guide fetch on map
- EPG signals: SD sources queue guide fetch when a channel is mapped
"""
import hashlib
@ -772,13 +773,167 @@ class ParseSchedulesDirectTimeTests(TestCase):
# Signal tests
# ---------------------------------------------------------------------------
class SDSingleEpgFetchTests(TestCase):
"""Per-channel SD guide fetch on map (epg_id_only path)."""
MAPPED_STATION = '10001'
def _make_sd_source(self, updated_at=None):
source = EPGSource.objects.create(
name='SD Single EPG',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
if updated_at is not None:
EPGSource.objects.filter(id=source.id).update(updated_at=updated_at)
source.refresh_from_db()
return source
def _lineup_get_side_effect(self, url, **kwargs):
if url.endswith('/status'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'systemStatus': [{'status': 'Online'}]}),
)
if url.endswith('/lineups'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'lineups': [{'lineupID': 'USA-TEST-X'}]}),
)
raise AssertionError(f'Unexpected GET URL: {url}')
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
def test_fetch_sd_guide_skips_when_program_data_exists(
self, mock_renewer, mock_release, mock_acquire,
):
from apps.epg.tasks import fetch_sd_guide_for_epg
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
start = timezone.now()
ProgramData.objects.create(
epg=epg,
start_time=start,
end_time=start + timedelta(hours=1),
title='Existing',
tvg_id=epg.tvg_id,
)
with patch('apps.epg.tasks.fetch_schedules_direct') as mock_fetch:
result = fetch_sd_guide_for_epg(epg.id)
self.assertEqual(result, 'Guide data already present')
mock_fetch.assert_not_called()
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
def test_parse_programs_for_tvg_id_delegates_to_sd_fetch(
self, mock_renewer, mock_release, mock_acquire,
):
from apps.epg.tasks import parse_programs_for_tvg_id
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
with patch('apps.epg.tasks.fetch_sd_guide_for_epg', return_value='SD guide fetch complete') as mock_sd:
result = parse_programs_for_tvg_id(epg.id)
mock_sd.assert_called_once_with(epg.id, force=False)
self.assertEqual(result, 'SD guide fetch complete')
@patch('apps.epg.tasks.SD_DAYS_TO_FETCH', 3)
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_single_epg_fetch_skips_lineup_sync_and_updated_at(
self, mock_post, mock_get, mock_send_epg_update,
):
from apps.epg.tasks import fetch_schedules_direct
prior_updated = timezone.now() - timedelta(hours=1)
source = self._make_sd_source(updated_at=prior_updated)
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
Channel.objects.create(name='Mapped Ch', epg_data=epg)
date_list = [
(date.today() + timedelta(days=i)).strftime('%Y-%m-%d')
for i in range(3)
]
mock_get.side_effect = self._lineup_get_side_effect
schedule_payload = [{
'stationID': self.MAPPED_STATION,
'metadata': {'startDate': date_list[0], 'md5': 'md5-new', 'modified': '2026-06-11T00:00:00Z'},
'programs': [{
'programID': 'EP000000000001',
'airDateTime': f'{date_list[0]}T12:00:00Z',
'duration': 3600,
'md5': 'prog-md5-1',
}],
}]
program_payload = [{
'programID': 'EP000000000001',
'titles': [{'title120': 'Test Show'}],
}]
def post_side_effect(url, **kwargs):
if url.endswith('/token'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok'}),
)
if url.endswith('/schedules/md5'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
self.MAPPED_STATION: {
ds: {'code': 0, 'md5': f'md5-{ds}', 'lastModified': '2026-06-11T00:00:00Z'}
for ds in date_list
},
}),
)
if url.endswith('/schedules'):
return MagicMock(status_code=200, json=MagicMock(return_value=schedule_payload))
if url.endswith('/programs'):
return MagicMock(status_code=200, json=MagicMock(return_value=program_payload))
raise AssertionError(f'Unexpected POST URL: {url}')
mock_post.side_effect = post_side_effect
fetch_schedules_direct(source, epg_id_only=epg.id)
lineup_detail_calls = [
c for c in mock_get.call_args_list
if '/lineups/' in c[0][0] and not c[0][0].endswith('/lineups')
]
self.assertEqual(lineup_detail_calls, [])
source.refresh_from_db()
self.assertEqual(source.updated_at, prior_updated)
self.assertEqual(ProgramData.objects.filter(epg=epg).count(), 1)
class SDSourceSignalTests(TestCase):
"""SD EPG sources must skip the XMLTV program parser signal."""
"""SD EPG sources queue per-EPG guide fetch when a channel is mapped."""
@patch('apps.channels.signals.parse_programs_for_tvg_id')
def test_sd_source_skips_xmltv_parse_on_channel_create(self, mock_parse):
"""Creating a channel linked to an SD EPG source must not trigger
the XMLTV program parser SD data is handled by fetch_schedules_direct."""
def test_sd_source_queues_guide_fetch_on_channel_create(self, mock_parse):
from apps.epg.models import EPGData
from apps.channels.models import Channel
@ -799,7 +954,7 @@ class SDSourceSignalTests(TestCase):
epg_data=epg_data,
)
mock_parse.delay.assert_not_called()
mock_parse.delay.assert_called_once_with(epg_data.id)
# ---------------------------------------------------------------------------