Dispatcharr/frontend/src/utils/guideUtils.js
2026-05-25 18:08:40 -07:00

416 lines
12 KiB
JavaScript

import {
convertToMs,
initializeTime,
startOfDay,
isBefore,
isAfter,
isSame,
add,
diff,
format,
getNow,
getNowMs,
roundToNearest,
} from './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 (values) => {
await API.createRecording(values);
};
export const createSeriesRule = async (values) => {
await API.createSeriesRule(values);
};
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);
};