mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
429 lines
12 KiB
JavaScript
429 lines
12 KiB
JavaScript
import {
|
|
convertToMs,
|
|
initializeTime,
|
|
startOfDay,
|
|
isBefore,
|
|
isAfter,
|
|
isSame,
|
|
add,
|
|
diff,
|
|
format,
|
|
getNow,
|
|
getNowMs,
|
|
roundToNearest,
|
|
} from '../utils/dateTimeUtils.js';
|
|
import API from '../api.js';
|
|
|
|
export const PROGRAM_HEIGHT = 90;
|
|
/** Layout constants */
|
|
export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
|
export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
|
|
export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
|
|
export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
|
/** Pixels per millisecond on the guide timeline. */
|
|
export const PX_PER_MS = MINUTE_BLOCK_WIDTH / (MINUTE_INCREMENT * 60000);
|
|
/** Gap in pixels between adjacent program cards. */
|
|
export const PROGRAM_GAP_PX = 2;
|
|
|
|
export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
|
|
const map = new Map();
|
|
channels.forEach((channel) => {
|
|
const tvgRecord = channel.epg_data_id
|
|
? tvgsById[channel.epg_data_id]
|
|
: null;
|
|
|
|
// For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel
|
|
// This prevents multiple channels with the same dummy EPG from showing identical data
|
|
let tvgId;
|
|
if (tvgRecord?.epg_source) {
|
|
const epgSource = epgs[tvgRecord.epg_source];
|
|
if (epgSource?.source_type === 'dummy') {
|
|
// Dummy EPG: use channel UUID for uniqueness
|
|
tvgId = channel.uuid;
|
|
} else {
|
|
// Regular EPG: use tvg_id from EPG data, or fall back to channel UUID
|
|
tvgId = tvgRecord.tvg_id ?? channel.uuid;
|
|
}
|
|
} else {
|
|
// No EPG data: use channel UUID
|
|
tvgId = channel.uuid;
|
|
}
|
|
|
|
if (tvgId) {
|
|
const tvgKey = String(tvgId);
|
|
if (!map.has(tvgKey)) {
|
|
map.set(tvgKey, []);
|
|
}
|
|
map.get(tvgKey).push(channel.id);
|
|
}
|
|
});
|
|
return map;
|
|
}
|
|
|
|
export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
|
|
if (!programs?.length || !channelIdByTvgId?.size) {
|
|
return new Map();
|
|
}
|
|
|
|
const map = new Map();
|
|
const nowMs = getNowMs();
|
|
|
|
programs.forEach((program) => {
|
|
const channelIds = channelIdByTvgId.get(String(program.tvg_id));
|
|
if (!channelIds || channelIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const startMs = program.startMs ?? convertToMs(program.start_time);
|
|
const endMs = program.endMs ?? convertToMs(program.end_time);
|
|
|
|
const programData = {
|
|
...program,
|
|
startMs,
|
|
endMs,
|
|
programStart: initializeTime(startMs),
|
|
programEnd: initializeTime(endMs),
|
|
// Precompute live/past status
|
|
isLive: nowMs >= startMs && nowMs < endMs,
|
|
isPast: nowMs >= endMs,
|
|
};
|
|
|
|
// Add this program to all channels that share the same TVG ID
|
|
channelIds.forEach((channelId) => {
|
|
if (!map.has(channelId)) {
|
|
map.set(channelId, []);
|
|
}
|
|
map.get(channelId).push(programData);
|
|
});
|
|
});
|
|
|
|
map.forEach((list) => {
|
|
list.sort((a, b) => a.startMs - b.startMs);
|
|
});
|
|
|
|
return map;
|
|
};
|
|
|
|
export function computeRowHeights(
|
|
filteredChannels,
|
|
defaultHeight = PROGRAM_HEIGHT
|
|
) {
|
|
if (!filteredChannels?.length) {
|
|
return [];
|
|
}
|
|
|
|
return filteredChannels.map(() => defaultHeight);
|
|
}
|
|
|
|
export const fetchPrograms = async () => {
|
|
console.log('Fetching program grid...');
|
|
const fetched = await API.getGrid(); // GETs your EPG grid
|
|
console.log(`Received ${fetched.length} programs`);
|
|
|
|
return fetched.map((program) => {
|
|
return {
|
|
...program,
|
|
startMs: convertToMs(program.start_time),
|
|
endMs: convertToMs(program.end_time),
|
|
};
|
|
});
|
|
};
|
|
|
|
export const sortChannels = (channels) => {
|
|
// Include ALL channels, sorted by channel number - don't filter by EPG data
|
|
const sortedChannels = Object.values(channels).sort(
|
|
(a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity)
|
|
);
|
|
|
|
console.log(`Using all ${sortedChannels.length} available channels`);
|
|
return sortedChannels;
|
|
};
|
|
|
|
export const filterGuideChannels = (
|
|
guideChannels,
|
|
searchQuery,
|
|
selectedGroupId,
|
|
selectedProfileId,
|
|
profiles
|
|
) => {
|
|
return guideChannels.filter((channel) => {
|
|
// Search filter
|
|
if (searchQuery) {
|
|
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
return false;
|
|
}
|
|
|
|
// Channel group filter
|
|
if (selectedGroupId !== 'all') {
|
|
if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
|
|
}
|
|
|
|
// Profile filter
|
|
if (selectedProfileId !== 'all') {
|
|
const profileChannels = profiles[selectedProfileId]?.channels || [];
|
|
const enabledChannelIds = Array.isArray(profileChannels)
|
|
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
|
|
: profiles[selectedProfileId]?.channels instanceof Set
|
|
? Array.from(profiles[selectedProfileId].channels)
|
|
: [];
|
|
|
|
if (!enabledChannelIds.includes(channel.id)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
export const calculateEarliestProgramStart = (programs, defaultStart) => {
|
|
if (!programs.length) return defaultStart;
|
|
return programs.reduce((acc, p) => {
|
|
const s = initializeTime(p.start_time);
|
|
return isBefore(s, acc) ? s : acc;
|
|
}, defaultStart);
|
|
};
|
|
|
|
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
|
|
if (!programs.length) return defaultEnd;
|
|
return programs.reduce((acc, p) => {
|
|
const e = initializeTime(p.end_time);
|
|
return isAfter(e, acc) ? e : acc;
|
|
}, defaultEnd);
|
|
};
|
|
|
|
export const calculateStart = (earliestProgramStart, defaultStart) => {
|
|
return isBefore(earliestProgramStart, defaultStart)
|
|
? earliestProgramStart
|
|
: defaultStart;
|
|
};
|
|
|
|
export const calculateEnd = (latestProgramEnd, defaultEnd) => {
|
|
return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
|
|
};
|
|
|
|
export const mapChannelsById = (guideChannels) => {
|
|
const map = new Map();
|
|
guideChannels.forEach((channel) => {
|
|
map.set(channel.id, channel);
|
|
});
|
|
return map;
|
|
};
|
|
|
|
const _terminalStatuses = new Set([
|
|
'stopped',
|
|
'completed',
|
|
'interrupted',
|
|
'failed',
|
|
]);
|
|
|
|
export const mapRecordingsByProgramId = (recordings) => {
|
|
const map = new Map();
|
|
(recordings || []).forEach((recording) => {
|
|
const programId = recording?.custom_properties?.program?.id;
|
|
const status = recording?.custom_properties?.status;
|
|
// Only show indicator for pending/active recordings, not terminal ones
|
|
if (programId != null && !_terminalStatuses.has(status)) {
|
|
map.set(programId, recording);
|
|
}
|
|
});
|
|
return map;
|
|
};
|
|
|
|
export const formatTime = (time, dateFormat) => {
|
|
const today = startOfDay(getNow());
|
|
const tomorrow = add(today, 1, 'day');
|
|
const weekLater = add(today, 7, 'day');
|
|
const day = startOfDay(time);
|
|
|
|
if (isSame(day, today, 'day')) {
|
|
return 'Today';
|
|
} else if (isSame(day, tomorrow, 'day')) {
|
|
return 'Tomorrow';
|
|
} else if (isBefore(day, weekLater)) {
|
|
// Within a week, show day name
|
|
return format(time, 'dddd');
|
|
} else {
|
|
// Beyond a week, show month and day
|
|
return format(time, dateFormat);
|
|
}
|
|
};
|
|
|
|
export const calculateHourTimeline = (start, end, formatDayLabel) => {
|
|
const hours = [];
|
|
let current = start;
|
|
let currentDay = null;
|
|
|
|
while (isBefore(current, end)) {
|
|
// Check if we're entering a new day
|
|
const day = startOfDay(current);
|
|
const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
|
|
|
|
if (isNewDay) {
|
|
currentDay = day;
|
|
}
|
|
|
|
// Add day information to our hour object
|
|
hours.push({
|
|
time: current,
|
|
isNewDay,
|
|
dayLabel: formatDayLabel(current),
|
|
});
|
|
|
|
current = add(current, 1, 'hour');
|
|
}
|
|
return hours;
|
|
};
|
|
|
|
export const calculateNowPosition = (now, start, end) => {
|
|
if (isBefore(now, start) || isAfter(now, end)) return -1;
|
|
const minutesSinceStart = diff(now, start, 'minute');
|
|
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
|
};
|
|
|
|
export const calculateScrollPosition = (now, start) => {
|
|
const roundedNow = roundToNearest(now, 30);
|
|
const nowOffset = diff(roundedNow, start, 'minute');
|
|
const scrollPosition =
|
|
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
|
|
|
|
return Math.max(scrollPosition, 0);
|
|
};
|
|
|
|
export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
|
|
const channelIds = channelIdByTvgId.get(String(tvgId));
|
|
if (!channelIds || channelIds.length === 0) {
|
|
return null;
|
|
}
|
|
// Return the first channel that matches this TVG ID
|
|
return channelById.get(channelIds[0]) || null;
|
|
};
|
|
|
|
export const fetchRules = async () => {
|
|
return await API.listSeriesRules();
|
|
};
|
|
|
|
export const getRuleByProgram = (rules, program) => {
|
|
return (rules || []).find(
|
|
(r) =>
|
|
String(r.tvg_id) === String(program.tvg_id) &&
|
|
(!r.title || r.title === program.title)
|
|
);
|
|
};
|
|
|
|
export const createRecording = async (channel, program) => {
|
|
await API.createRecording({
|
|
channel: `${channel.id}`,
|
|
start_time: program.start_time,
|
|
end_time: program.end_time,
|
|
custom_properties: { program },
|
|
});
|
|
};
|
|
|
|
export const createSeriesRule = async (program, mode) => {
|
|
await API.createSeriesRule({
|
|
tvg_id: program.tvg_id,
|
|
mode,
|
|
title: program.title,
|
|
});
|
|
};
|
|
|
|
export const evaluateSeriesRule = async (program) => {
|
|
await API.evaluateSeriesRules(program.tvg_id);
|
|
};
|
|
|
|
export const calculateLeftScrollPosition = (program, start) => {
|
|
const programStartMs = program.startMs ?? convertToMs(program.start_time);
|
|
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
|
|
|
|
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
|
};
|
|
|
|
export const calculateDesiredScrollPosition = (leftPx) => {
|
|
return Math.max(0, leftPx - 20);
|
|
};
|
|
|
|
export const calculateScrollPositionByTimeClick = (
|
|
event,
|
|
clickedTime,
|
|
start
|
|
) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const clickPositionX = event.clientX - rect.left;
|
|
const percentageAcross = clickPositionX / rect.width;
|
|
const minuteWithinHour = percentageAcross * 60;
|
|
|
|
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
|
|
|
|
const adjustedTime =
|
|
snappedMinute === 60
|
|
? add(clickedTime, 1, 'hour').minute(0)
|
|
: clickedTime.minute(snappedMinute);
|
|
|
|
const snappedOffset = diff(adjustedTime, start, 'minute');
|
|
return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
|
};
|
|
|
|
export const getGroupOptions = (channelGroups, guideChannels) => {
|
|
const options = [{ value: 'all', label: 'All Channel Groups' }];
|
|
|
|
if (channelGroups && guideChannels.length > 0) {
|
|
// Get unique channel group IDs from the channels that have program data
|
|
const usedGroupIds = new Set();
|
|
guideChannels.forEach((channel) => {
|
|
if (channel.channel_group_id) {
|
|
usedGroupIds.add(channel.channel_group_id);
|
|
}
|
|
});
|
|
// Only add groups that are actually used by channels in the guide
|
|
Object.values(channelGroups)
|
|
.filter((group) => usedGroupIds.has(group.id))
|
|
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
|
|
.forEach((group) => {
|
|
options.push({
|
|
value: group.id.toString(),
|
|
label: group.name,
|
|
});
|
|
});
|
|
}
|
|
return options;
|
|
};
|
|
|
|
export const getProfileOptions = (profiles) => {
|
|
const options = [{ value: 'all', label: 'All Profiles' }];
|
|
|
|
if (profiles) {
|
|
Object.values(profiles).forEach((profile) => {
|
|
if (profile.id !== '0') {
|
|
// Skip the 'All' default profile
|
|
options.push({
|
|
value: profile.id.toString(),
|
|
label: profile.name,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
export const calcProgressPct = (nowMs, startMs, durationMs) => {
|
|
const elapsedPx = (nowMs - startMs) * PX_PER_MS;
|
|
const durationPx = durationMs * PX_PER_MS;
|
|
const cardWidth = durationPx - PROGRAM_GAP_PX * 2;
|
|
return Math.min(1, Math.max(0, (elapsedPx - PROGRAM_GAP_PX) / cardWidth));
|
|
};
|
|
|
|
export const formatSeasonEpisode = (season, episode) => {
|
|
if (season != null && episode != null)
|
|
return `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
|
|
if (season != null) return `S${String(season).padStart(2, '0')}`;
|
|
if (episode != null) return `E${String(episode).padStart(2, '0')}`;
|
|
return null;
|
|
};
|
|
|
|
export const deleteSeriesRuleByTvgId = async (tvg_id, title) => {
|
|
await API.deleteSeriesRule(tvg_id, title);
|
|
};
|
|
|
|
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
|
|
await API.evaluateSeriesRules(tvg_id);
|
|
};
|