diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index 4598f8b2..e9ac8581 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -19,9 +19,10 @@ from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
from apps.epg.models import EPGData
from django.db import connection, transaction
+from django.urls import reverse
from rest_framework import serializers
from django.utils import timezone
-from core.utils import validate_flexible_url, build_absolute_uri_with_port
+from core.utils import validate_flexible_url
class LogoSerializer(serializers.ModelSerializer):
@@ -56,12 +57,18 @@ class LogoSerializer(serializers.ModelSerializer):
return instance
def get_cache_url(self, obj):
+ # Cache-busting: append a short hash of the logo's source URL so the browser
+ # fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
+ # The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
+ # See SD integration PR notes for context on why this was added.
+ import hashlib
+ url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
+ base_path = reverse("api:channels:logo-cache", args=[obj.id])
+ cache_url = f"{base_path}?v={url_hash}"
request = self.context.get("request")
- if not request:
- return f"/api/channels/logos/{obj.id}/cache/"
- if not hasattr(self, "_cache_url_prefix"):
- self._cache_url_prefix = build_absolute_uri_with_port(request, "")
- return f"{self._cache_url_prefix}/api/channels/logos/{obj.id}/cache/"
+ if request:
+ return request.build_absolute_uri(cache_url)
+ return cache_url
def get_channel_count(self, obj):
"""Get the number of channels using this logo"""
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 1223a6a0..d8912f0d 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -381,6 +381,11 @@ export const WebsocketProvider = ({ children }) => {
) {
API.batchSetEPG(parsedEvent.data.associations);
}
+
+ // Refresh EPG store first, then requery channels so the table
+ // cross-references updated epg_data_id assignments immediately
+ fetchEPGData();
+ API.requeryChannels();
break;
case 'epg_matching_progress': {
@@ -965,12 +970,6 @@ export const WebsocketProvider = ({ children }) => {
break;
}
- case 'ip_lookup_complete': {
- const { type: _t, ...ipData } = parsedEvent.data;
- useSettingsStore.getState().setEnvironmentFields(ipData);
- break;
- }
-
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 30d69c85..43f53040 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -3879,6 +3879,25 @@ export default class API {
}
}
+ static async updateSDSettings(sourceId, settings) {
+ try {
+ // Read current custom_properties from the store to merge, not replace
+ const epgs = useEPGsStore.getState().epgs;
+ const source = epgs[sourceId];
+ const cp = { ...(source?.custom_properties || {}), ...settings };
+
+ const response = await request(`${host}/api/epg/sources/${sourceId}/`, {
+ method: 'PATCH',
+ body: { custom_properties: cp },
+ });
+
+ useEPGsStore.getState().updateEPG(response);
+ return response;
+ } catch (e) {
+ errorNotification('Failed to update Schedules Direct settings', e);
+ }
+ }
+
static async searchSDLineups(sourceId, country, postalcode) {
try {
const response = await request(
diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx
index 369011f3..6215e40d 100644
--- a/frontend/src/components/forms/EPG.jsx
+++ b/frontend/src/components/forms/EPG.jsx
@@ -18,6 +18,8 @@ import {
Badge,
ScrollArea,
Table,
+ Switch,
+ UnstyledButton,
} from '@mantine/core';
import { TriangleAlert, Trash2, Plus, Search } from 'lucide-react';
import { isNotEmpty, useForm } from '@mantine/form';
@@ -45,6 +47,138 @@ const SD_COUNTRIES_FALLBACK = [
{ value: 'NZL', label: 'New Zealand' },
];
+// ESPN HD logo previews — packaged as static URLs so no API call is needed.
+// These are publicly accessible S3 URLs that don't require authentication.
+const SD_LOGO_PREVIEW_BASE = 'https://schedulesdirect-api20141201-logos.s3.dualstack.us-east-1.amazonaws.com/stationLogos/s32645';
+const SD_LOGO_STYLES = [
+ { value: 'dark', label: 'Dark', url: `${SD_LOGO_PREVIEW_BASE}_dark_360w_270h.png` },
+ { value: 'white', label: 'White', url: `${SD_LOGO_PREVIEW_BASE}_white_360w_270h.png` },
+ { value: 'gray', label: 'Gray', url: `${SD_LOGO_PREVIEW_BASE}_gray_360w_270h.png` },
+ { value: 'light', label: 'Light', url: `${SD_LOGO_PREVIEW_BASE}_light_360w_270h.png` },
+];
+
+// ─── SD Settings: Logo toggle + style selector + Poster toggle ──────────────
+const SDSettings = ({ sourceId, customProperties }) => {
+ const cp = customProperties || {};
+ const [useSDLogos, setUseSDLogos] = useState(cp.use_sd_logos || false);
+ const [logoStyle, setLogoStyle] = useState(cp.logo_style || 'dark');
+ const [fetchPosters, setFetchPosters] = useState(cp.fetch_posters || false);
+ const [saving, setSaving] = useState(false);
+
+ // Sync from parent when customProperties changes
+ useEffect(() => {
+ const newCp = customProperties || {};
+ setUseSDLogos(newCp.use_sd_logos || false);
+ setLogoStyle(newCp.logo_style || 'dark');
+ setFetchPosters(newCp.fetch_posters || false);
+
+ // Persist the default logo_style if not already set
+ if (sourceId && !newCp.logo_style) {
+ API.updateSDSettings(sourceId, { logo_style: 'dark' });
+ }
+ }, [customProperties, sourceId]);
+
+ const saveSetting = async (key, value) => {
+ setSaving(true);
+ try {
+ await API.updateSDSettings(sourceId, { [key]: value });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleLogoToggle = (checked) => {
+ setUseSDLogos(checked);
+ saveSetting('use_sd_logos', checked);
+ };
+
+ const handleLogoChange = (style) => {
+ if (!useSDLogos) return;
+ setLogoStyle(style);
+ saveSetting('logo_style', style);
+ };
+
+ const handlePosterToggle = (checked) => {
+ setFetchPosters(checked);
+ saveSetting('fetch_posters', checked);
+ };
+
+ const logosDisabled = !useSDLogos;
+
+ return (
+
+ handleLogoToggle(e.currentTarget.checked)}
+ disabled={saving}
+ size="sm"
+ mb="sm"
+ />
+
+
+ Station Logo Style
+
+
+ Choose which logo variant to use for SD stations.
+
+
+ {SD_LOGO_STYLES.map((style) => (
+ handleLogoChange(style.value)}
+ style={{
+ border: !logosDisabled && logoStyle === style.value
+ ? '2px solid var(--mantine-color-blue-5)'
+ : '2px solid var(--mantine-color-default-border)',
+ borderRadius: 'var(--mantine-radius-sm)',
+ padding: 3,
+ opacity: logosDisabled ? 0.3 : (saving ? 0.6 : 1),
+ cursor: logosDisabled ? 'not-allowed' : (saving ? 'wait' : 'pointer'),
+ flex: 1,
+ textAlign: 'center',
+ pointerEvents: logosDisabled ? 'none' : 'auto',
+ }}
+ >
+
+
+ {style.label}
+
+
+ ))}
+
+
+
+
+ handlePosterToggle(e.currentTarget.checked)}
+ disabled={saving}
+ size="sm"
+ />
+
+ );
+};
+
+// ─── SD Lineup Manager ─────────────────────────────────────────────────────
const SDLineupManager = ({ sourceId }) => {
const [countries, setCountries] = useState(SD_COUNTRIES_FALLBACK);
const [activeLineups, setActiveLineups] = useState([]);
@@ -175,7 +309,7 @@ const SDLineupManager = ({ sourceId }) => {
const atMax = activeLineups.length >= maxLineups;
return (
-
+
@@ -335,10 +469,12 @@ const SDLineupManager = ({ sourceId }) => {
);
};
+// ─── Main EPG Form ──────────────────────────────────────────────────────────
const EPG = ({ epg = null, isOpen, onClose }) => {
const [sourceType, setSourceType] = useState('xmltv');
const [scheduleType, setScheduleType] = useState('interval');
const [savedEpgId, setSavedEpgId] = useState(null);
+ const [sdCustomProps, setSdCustomProps] = useState(null);
const form = useForm({
mode: 'uncontrolled',
@@ -372,22 +508,18 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
values.cron_expression = '';
}
- if (epg?.id) {
- if (!epg || typeof epg !== 'object' || !epg.id) {
- showNotification({
- title: 'Error',
- message: 'Invalid EPG data. Please close and reopen this form.',
- color: 'red',
- });
- return;
- }
+ const existingId = epg?.id || savedEpgId;
- await updateEPG(values, epg);
+ if (existingId) {
+ const epgObj = epg || { id: existingId };
+ await updateEPG(values, epgObj);
onClose();
} else {
const result = await addEPG(values);
if (result?.id) {
setSavedEpgId(result.id);
+ // Load custom_properties for the new source
+ setSdCustomProps(result.custom_properties || {});
} else {
form.reset();
onClose();
@@ -411,6 +543,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
form.setValues(values);
setSourceType(epg.source_type);
setSavedEpgId(epg.id);
+ setSdCustomProps(epg.custom_properties || {});
setScheduleType(
epg.cron_expression && epg.cron_expression.trim() !== ''
? 'cron'
@@ -421,6 +554,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
setSourceType('xmltv');
setScheduleType('interval');
setSavedEpgId(null);
+ setSdCustomProps(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [epg]);
@@ -433,6 +567,9 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
const handleClose = () => {
form.reset();
setSavedEpgId(null);
+ setSdCustomProps(null);
+ setSourceType('xmltv');
+ setScheduleType('interval');
onClose();
};
@@ -441,14 +578,15 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
}
const isSD = sourceType === 'schedules_direct';
+ const hasSDPanel = isSD && savedEpgId;
return (
<>
-
+
-
- {/* Lineup Manager — only shown for saved SD sources */}
- {isSD && savedEpgId && (
-
- )}
+ {/* Right Column — SD settings + Lineup Manager */}
+ {hasSDPanel && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
{/* Full Width Section */}
@@ -623,7 +772,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
{isSD && !savedEpgId && (
- Save this source first to manage Schedules Direct lineups.
+ Save this source first to manage Schedules Direct settings and lineups.
)}
@@ -632,7 +781,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
Cancel