Dispatcharr/frontend/src/utils/forms/ChannelUtils.js

276 lines
9.1 KiB
JavaScript

import API from '../../api.js';
// Fields that users can override on an auto-synced channel. Mirrors
// `OVERRIDABLE_FIELDS` in `apps/channels/managers.py`. Keep the two in sync.
export const OVERRIDABLE_FIELDS = [
'name',
'channel_number',
'channel_group_id',
'logo_id',
'tvg_id',
'tvc_guide_stationid',
'epg_data_id',
'stream_profile_id',
];
// Display labels for the override fields above.
export const OVERRIDE_FIELD_LABELS = {
name: 'Name',
channel_number: 'Channel Number',
channel_group_id: 'Channel Group',
logo_id: 'Logo',
tvg_id: 'TVG-ID',
tvc_guide_stationid: 'Gracenote Station ID',
epg_data_id: 'EPG',
stream_profile_id: 'Stream Profile',
};
export const matchChannelEpg = (channel) => {
return API.matchChannelEpg(channel.id);
};
export const createLogo = (newLogoData) => {
return API.createLogo(newLogoData);
};
const setChannelEPG = (channel, values) => {
return API.setChannelEPG(channel.id, values.epg_data_id);
};
const updateChannel = (values) => {
return API.updateChannel(values);
};
export const addChannel = (channel) => {
return API.addChannel(channel);
};
export const requeryChannels = () => {
API.requeryChannels();
};
// PATCH semantic: `override: null` deletes the override row; per-field
// nulls only clear the matching field.
export const clearChannelOverrides = (channelId) => {
return API.updateChannel({ id: channelId, override: null });
};
// Coerce a form value to the backend's storage shape so equality
// comparisons against channel data don't drift on type mismatches.
export const normalizeFieldValue = (field, value) => {
if (value === '' || value === null || value === undefined || value === '-1') {
return null;
}
// The stream_profile and logo pickers encode "(use default)" as '0',
// which is semantically a null FK on the Channel row.
if ((field === 'stream_profile_id' || field === 'logo_id') && value === '0') {
return null;
}
if (field === 'channel_number') {
const n = parseFloat(value);
return Number.isFinite(n) ? n : null;
}
if (
field === 'channel_group_id' ||
field === 'logo_id' ||
field === 'epg_data_id' ||
field === 'stream_profile_id'
) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : null;
}
return value;
};
// Per-field form shape for FK pickers (string for popovers, raw for
// the EPG select); used by the reset-to-provider affordance.
const PROVIDER_FORM_VALUE_BUILDERS = {
channel_group_id: (channel) =>
channel?.channel_group_id != null ? `${channel.channel_group_id}` : '',
stream_profile_id: (channel) =>
channel?.stream_profile_id != null ? `${channel.stream_profile_id}` : '0',
logo_id: (channel) => (channel?.logo_id != null ? `${channel.logo_id}` : ''),
epg_data_id: (channel) => channel?.epg_data_id ?? '',
};
export const getProviderFormValue = (channel, field) => {
const builder = PROVIDER_FORM_VALUE_BUILDERS[field];
if (builder) return builder(channel);
return channel?.[field] ?? '';
};
// Form value differs from the channel's provider value. Manual
// channels always return false (no provider value to compare to).
export const isFormFieldOverridden = (channel, field, formValue) => {
if (!channel?.auto_created) return false;
const normalizedForm = normalizeFieldValue(field, formValue);
const normalizedProvider = normalizeFieldValue(field, channel[field]);
return normalizedForm !== normalizedProvider;
};
// Human labels for the table's overrides indicator tooltip.
export const listOverriddenFields = (channel) => {
if (!channel?.override) return [];
return OVERRIDABLE_FIELDS.filter((field) => {
const value = channel.override[field];
return value !== null && value !== undefined;
}).map((field) => OVERRIDE_FIELD_LABELS[field]);
};
// "Provider: <value>" subtext for auto-synced channels (null for manual).
export const getProviderHint = (channel, field) => {
if (!channel?.auto_created) return null;
const providerValue = channel[field];
const display =
providerValue === null ||
providerValue === undefined ||
providerValue === ''
? '(empty)'
: providerValue;
return `Provider: ${display}`;
};
// FK provider hint that resolves the ID to a display name via lookup.
export const getFkProviderHint = (channel, field, lookup) => {
if (!channel?.auto_created) return null;
const providerId = channel[field];
if (providerId === null || providerId === undefined) {
return 'Provider: (none)';
}
const entry = lookup?.[providerId];
const display = entry?.name || entry?.tvg_id || String(providerId);
return `Provider: ${display}`;
};
// Build the override PATCH payload by diffing form values against
// provider values. Matching fields become null (clear); diverging
// fields carry the form value.
export const buildOverridePayload = (channel, formattedValues) => {
if (!channel) return undefined;
const payload = {};
let anyOverride = false;
for (const field of OVERRIDABLE_FIELDS) {
const formValue = normalizeFieldValue(field, formattedValues[field]);
const providerValue = normalizeFieldValue(field, channel[field]);
if (formValue === null && providerValue === null) continue;
if (formValue !== providerValue) {
payload[field] = formValue;
if (formValue !== null) anyOverride = true;
} else {
payload[field] = null;
}
}
if (!anyOverride) {
// Every field matches provider; explicit null deletes the row.
return null;
}
return payload;
};
// Prefer the backend-resolved effective_* so the form loads with the
// overridden value; fall back to the raw field otherwise.
const effective = (channel, field) => {
if (!channel) return undefined;
const effKey = `effective_${field}`;
if (effKey in channel && channel[effKey] !== undefined) {
return channel[effKey];
}
return channel[field];
};
export const getChannelFormDefaultValues = (channel, channelGroups) => {
const name = effective(channel, 'name') ?? '';
const channelNumber = effective(channel, 'channel_number');
const groupId = effective(channel, 'channel_group_id');
const streamProfileId = effective(channel, 'stream_profile_id');
const tvgId = effective(channel, 'tvg_id');
const gracenoteId = effective(channel, 'tvc_guide_stationid');
const epgDataId = effective(channel, 'epg_data_id');
const logoId = effective(channel, 'logo_id');
return {
name: name || '',
channel_number:
channelNumber !== null && channelNumber !== undefined
? channelNumber
: '',
channel_group_id: groupId
? `${groupId}`
: Object.keys(channelGroups).length > 0
? Object.keys(channelGroups)[0]
: '',
stream_profile_id: streamProfileId ? `${streamProfileId}` : '0',
tvg_id: tvgId || '',
tvc_guide_stationid: gracenoteId || '',
epg_data_id: epgDataId ?? '',
logo_id: logoId ? `${logoId}` : '',
user_level: `${channel?.user_level ?? '0'}`,
is_adult: channel?.is_adult ?? false,
hidden_from_output: channel?.hidden_from_output ?? false,
};
};
export const getFormattedValues = (values) => {
const formattedValues = { ...values };
// Convert empty or "0" stream_profile_id to null for the API
if (
!formattedValues.stream_profile_id ||
formattedValues.stream_profile_id === '0'
) {
formattedValues.stream_profile_id = null;
}
// Ensure tvg_id is properly included (no empty strings)
formattedValues.tvg_id = formattedValues.tvg_id || null;
// Ensure tvc_guide_stationid is properly included (no empty strings)
formattedValues.tvc_guide_stationid =
formattedValues.tvc_guide_stationid || null;
return formattedValues;
};
export const handleEpgUpdate = async (
channel,
values,
formattedValues,
channelStreams
) => {
// Auto-synced channels route identity edits into the override row. Sync
// keeps writing provider values to Channel.* unmodified, so the override
// is what actually persists user changes across refreshes. `hidden_from_output`
// stays as a direct Channel field even for auto-created channels because
// it is a status flag, not a value replacement.
if (channel.auto_created) {
const overridePayload = buildOverridePayload(channel, formattedValues);
const payload = {
id: channel.id,
hidden_from_output: formattedValues.hidden_from_output,
};
if (overridePayload !== undefined) {
payload.override = overridePayload;
}
await updateChannel(payload);
return;
}
// Manual channels: existing behavior preserved. When the EPG has changed,
// the dedicated set-EPG endpoint triggers an EPG refresh; other field
// updates go through the regular PATCH and are skipped entirely when
// there is nothing besides epg_data_id to update.
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
await setChannelEPG(channel, values);
const { epg_data_id: _epg_data_id, ...otherValues } = formattedValues;
if (Object.keys(otherValues).length > 0) {
await updateChannel({
id: channel.id,
...otherValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} else {
await updateChannel({
id: channel.id,
...formattedValues,
streams: channelStreams.map((stream) => stream.id),
});
}
};