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} + + {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 ( <> - +
- + {/* Left Column */} - + { - {/* Right Column */} - + {/* Middle Column */} + {!isSD && ( { - - {/* 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