diff --git a/apps/epg/migrations/0027_epgdata_lineup.py b/apps/epg/migrations/0027_epgdata_lineup.py new file mode 100644 index 00000000..762ab03c --- /dev/null +++ b/apps/epg/migrations/0027_epgdata_lineup.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0026_epgsourceindex'), + ] + + operations = [ + migrations.AddField( + model_name='epgdata', + name='lineup', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index b85453af..c1254e46 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -153,6 +153,7 @@ class EPGData(models.Model): tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) name = models.CharField(max_length=512) icon_url = models.URLField(max_length=500, null=True, blank=True) + lineup = models.CharField(max_length=255, null=True, blank=True) epg_source = models.ForeignKey( EPGSource, on_delete=models.CASCADE, diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index ed99efb4..9d283147 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -197,6 +197,7 @@ class EPGDataSerializer(serializers.ModelSerializer): 'tvg_id', 'name', 'icon_url', + 'lineup', 'epg_source', ] diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 83aaa275..1865166b 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -2971,12 +2971,20 @@ def fetch_schedules_direct( headers=_sd_headers(), timeout=30, ) - token_response.raise_for_status() - token_data = token_response.json() + # Parse the response body first so we can surface SD's own error message + # regardless of HTTP status code (SD sometimes returns 400 for app-level + # errors like SERVICE_OFFLINE that carry a meaningful JSON body). + try: + token_data = token_response.json() + except Exception: + token_response.raise_for_status() + raise auth_code = token_data.get('code', 0) - if auth_code != 0: - if auth_code == 4007: + if not token_response.ok or auth_code != 0: + if auth_code == 3000: + msg = "Schedules Direct is offline for maintenance. Try again later." + elif 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." @@ -2986,8 +2994,13 @@ def fetch_schedules_direct( 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." - else: + elif auth_code == 4003: + msg = "Schedules Direct: invalid username or password." + elif auth_code != 0: msg = f"Schedules Direct authentication failed (code {auth_code}): {token_data.get('message', 'Unknown error')}" + else: + token_response.raise_for_status() + raise RuntimeError(f"Unexpected SD token response: {token_response.text}") logger.error(msg) source.status = EPGSource.STATUS_ERROR source.last_message = msg @@ -3118,6 +3131,7 @@ def fetch_schedules_direct( lineup_id = lineup.get('lineupID') or lineup.get('lineup') if not lineup_id: continue + lineup_name = lineup.get('name') or lineup_id try: detail_response = requests.get( f"{SD_BASE_URL}/lineups/{lineup_id}", @@ -3143,6 +3157,8 @@ def fetch_schedules_direct( 'name': station.get('name', sid), 'callsign': station.get('callsign', ''), 'logo_url': logo_url, + # Preserve lineup from first encounter if station appears in multiple lineups + 'lineup': station_map.get(sid, {}).get('lineup') or lineup_name, } logger.debug(f"Fetched {len(detail_data.get('stations', []))} stations from lineup {lineup_id}") except requests.exceptions.RequestException as e: @@ -3182,6 +3198,7 @@ def fetch_schedules_direct( logo = info['logo_url'] if logo and len(logo) > icon_max_length: logo = None + lineup_val = info.get('lineup') if sid in existing_epg_map: epg_obj = existing_epg_map[sid] @@ -3192,6 +3209,9 @@ def fetch_schedules_direct( if epg_obj.icon_url != logo: epg_obj.icon_url = logo needs_update = True + if epg_obj.lineup != lineup_val: + epg_obj.lineup = lineup_val + needs_update = True if needs_update: epgs_to_update.append(epg_obj) else: @@ -3199,6 +3219,7 @@ def fetch_schedules_direct( tvg_id=sid, name=display_name, icon_url=logo, + lineup=lineup_val, epg_source=source, )) @@ -3206,7 +3227,7 @@ def fetch_schedules_direct( 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']) + EPGData.objects.bulk_update(epgs_to_update, ['name', 'icon_url', 'lineup']) logger.info(f"Updated {len(epgs_to_update)} existing EPGData entries.") gc.collect() diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 260bd4ad..d9a84502 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -1179,11 +1179,13 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => { setEpgPopoverOpened(false); }} > - {filteredTvgs[index].name && - filteredTvgs[index].tvg_id - ? `${filteredTvgs[index].name} (${filteredTvgs[index].tvg_id})` - : filteredTvgs[index].name || - filteredTvgs[index].tvg_id} + {(() => { + const tvg = filteredTvgs[index]; + const base = tvg.name && tvg.tvg_id + ? `${tvg.name} (${tvg.tvg_id})` + : tvg.name || tvg.tvg_id; + return tvg.lineup ? `${base} [${tvg.lineup}]` : base; + })()} )}