mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
fix: add missing updateSDSettings API method and EPG refresh handlers
Add updateSDSettings method to API client for SD logo style and settings management. Add fetchEPGData and requeryChannels calls after EPG match completion to ensure UI refreshes immediately. Add SD logo style picker and cache-busting for channel logos.
This commit is contained in:
parent
661d1690d2
commit
7c91508f3c
4 changed files with 209 additions and 35 deletions
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box>
|
||||
<Switch
|
||||
label="Use SD Station Logos"
|
||||
description="Apply Schedules Direct station logos to matched channels during refresh."
|
||||
checked={useSDLogos}
|
||||
onChange={(e) => handleLogoToggle(e.currentTarget.checked)}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
mb="sm"
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={500} mb="xs" c={logosDisabled ? 'dimmed' : undefined}>
|
||||
Station Logo Style
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
Choose which logo variant to use for SD stations.
|
||||
</Text>
|
||||
<Group gap={6} mb="md">
|
||||
{SD_LOGO_STYLES.map((style) => (
|
||||
<UnstyledButton
|
||||
key={style.value}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={style.url}
|
||||
alt={style.label}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 50,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
backgroundColor: style.value === 'white' || style.value === 'light'
|
||||
? '#333'
|
||||
: 'transparent',
|
||||
borderRadius: 2,
|
||||
filter: logosDisabled ? 'grayscale(100%)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" ta="center" mt={2} fw={!logosDisabled && logoStyle === style.value ? 600 : 400}
|
||||
c={logosDisabled ? 'dimmed' : undefined}>
|
||||
{style.label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Switch
|
||||
label="Fetch Program Posters"
|
||||
description="WARNING: USES ADDITIONAL API REQUESTS. Poster images are fetched on-demand and cached locally for 30 days. Initial viewing of new programs will consume API requests against your Schedules Direct rate limit."
|
||||
checked={fetchPosters}
|
||||
onChange={(e) => handlePosterToggle(e.currentTarget.checked)}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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 (
|
||||
<Box mt="md">
|
||||
<Box>
|
||||
<Divider my="sm" />
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={handleClose} title="EPG Source" size={700}>
|
||||
<Modal opened={isOpen} onClose={handleClose} title="EPG Source" size={hasSDPanel ? 1100 : 700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Group justify="space-between" align="top" wrap="nowrap">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<Stack gap="md" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
|
|
@ -541,8 +679,8 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
{/* Middle Column */}
|
||||
<Stack gap="md" style={{ flex: 1, minWidth: 0 }}>
|
||||
{!isSD && (
|
||||
<TextInput
|
||||
id="url"
|
||||
|
|
@ -610,12 +748,23 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Lineup Manager — only shown for saved SD sources */}
|
||||
{isSD && savedEpgId && (
|
||||
<SDLineupManager sourceId={savedEpgId} />
|
||||
)}
|
||||
{/* Right Column — SD settings + Lineup Manager */}
|
||||
{hasSDPanel && (
|
||||
<>
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
<Stack gap="md" style={{ flex: 1.2, minWidth: 0 }}>
|
||||
<ScrollArea h={550} offsetScrollbars>
|
||||
<SDSettings
|
||||
sourceId={savedEpgId}
|
||||
customProperties={sdCustomProps}
|
||||
/>
|
||||
<SDLineupManager sourceId={savedEpgId} />
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
|
|
@ -623,7 +772,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
|
||||
{isSD && !savedEpgId && (
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
Save this source first to manage Schedules Direct lineups.
|
||||
Save this source first to manage Schedules Direct settings and lineups.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
|
@ -632,7 +781,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
{(epg?.id || savedEpgId) ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue