From f1dde066c0bf1335cf9a13dd197cd7ace6c671a6 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:20:11 -0700 Subject: [PATCH 01/10] Extracted utils --- frontend/src/utils/dateTimeUtils.js | 36 +++ frontend/src/utils/forms/ChannelUtils.js | 2 +- .../src/utils/tables/ChannelsTableUtils.js | 233 ++++++++++++++++++ frontend/src/utils/tables/EPGsTableUtils.js | 55 +++++ frontend/src/utils/tables/LogosTableUtils.js | 62 +++++ frontend/src/utils/tables/M3UsTableUtils.js | 142 +++++++++++ .../src/utils/tables/StreamsTableUtils.js | 127 ++++++++++ 7 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/tables/ChannelsTableUtils.js create mode 100644 frontend/src/utils/tables/EPGsTableUtils.js create mode 100644 frontend/src/utils/tables/LogosTableUtils.js create mode 100644 frontend/src/utils/tables/M3UsTableUtils.js create mode 100644 frontend/src/utils/tables/StreamsTableUtils.js diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index f973e0b0..e198329c 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -372,3 +372,39 @@ export const MONTH_ABBR = [ 'nov', 'dec', ]; + +/** + * @param {number} seconds + * @param {object} [options] + * @param {'hms'|'hm'|'m'} [options.precision='hms'] - Segments to include + * @param {boolean} [options.alwaysShowHours=false] - Always include hours segment + * @param {string|null} [options.zeroValue=null] - Return this when seconds is 0/falsy + */ +export const formatDuration = (seconds, options = {}) => { + const { precision = 'hms', alwaysShowHours = false, zeroValue = null } = options; + + if (!seconds || seconds === 0) return zeroValue ?? '0:00'; + + const abs = Math.abs(seconds); + const h = Math.floor(abs / 3600); + const m = Math.floor((abs % 3600) / 60); + const s = Math.floor(abs % 60); + + const mm = m.toString().padStart(2, '0'); + const ss = s.toString().padStart(2, '0'); + const hh = h.toString().padStart(2, '0'); + + switch (precision) { + case 'm': + return `${Math.floor(abs / 60)}`; + case 'hm': + return (alwaysShowHours || h > 0) + ? `${hh}:${mm}` + : `${m}`; + case 'hms': + default: + return (alwaysShowHours || h > 0) + ? `${hh}:${mm}:${ss}` + : `${m}:${ss}`; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js index cc3743e3..40cf9a27 100644 --- a/frontend/src/utils/forms/ChannelUtils.js +++ b/frontend/src/utils/forms/ChannelUtils.js @@ -34,7 +34,7 @@ export const createLogo = (newLogoData) => { const setChannelEPG = (channel, values) => { return API.setChannelEPG(channel.id, values.epg_data_id); }; -const updateChannel = (values) => { +export const updateChannel = (values) => { return API.updateChannel(values); }; export const addChannel = (channel) => { diff --git a/frontend/src/utils/tables/ChannelsTableUtils.js b/frontend/src/utils/tables/ChannelsTableUtils.js new file mode 100644 index 00000000..a5cadb45 --- /dev/null +++ b/frontend/src/utils/tables/ChannelsTableUtils.js @@ -0,0 +1,233 @@ +import { + normalizeFieldValue, + OVERRIDABLE_FIELDS, +} from '../forms/ChannelUtils.js'; +import API from '../../api.js'; + +// Inline edits on auto-synced channels route into the override row so +// sync cannot overwrite them. If the new value matches the provider's, +// clear that field's override instead of writing a duplicate. Manual +// channels keep direct Channel.* writes. +export const buildInlinePatch = (rowOriginal, fieldId, newValue) => { + if (rowOriginal?.auto_created && OVERRIDABLE_FIELDS.includes(fieldId)) { + // Normalize both sides so a stringified form value compares + // cleanly against the typed provider value. + const formValue = normalizeFieldValue(fieldId, newValue); + const providerValue = normalizeFieldValue(fieldId, rowOriginal[fieldId]); + const overrideFieldValue = formValue === providerValue ? null : formValue; + return { + id: rowOriginal.id, + override: { [fieldId]: overrideFieldValue }, + }; + } + const normalized = + newValue === undefined || newValue === '' ? null : newValue; + return { + id: rowOriginal.id, + [fieldId]: normalized, + }; +}; + +export const getEpgOptions = (tvgsById, epgs) => { + const options = [{ value: 'null', label: 'Not Assigned' }]; + + // Convert tvgsById to an array and sort by EPG source name, then by tvg_id + const tvgsArray = Object.values(tvgsById).sort((a, b) => { + const aEpgName = + a.epg_source && epgs[a.epg_source] + ? epgs[a.epg_source].name + : a.epg_source || ''; + const bEpgName = + b.epg_source && epgs[b.epg_source] + ? epgs[b.epg_source].name + : b.epg_source || ''; + const epgCompare = aEpgName.localeCompare(bEpgName); + if (epgCompare !== 0) return epgCompare; + // Secondary sort by tvg_id + return (a.tvg_id || '').localeCompare(b.tvg_id || ''); + }); + + tvgsArray.forEach((tvg) => { + const epgSourceName = + tvg.epg_source && epgs[tvg.epg_source] + ? epgs[tvg.epg_source].name + : tvg.epg_source; + const tvgName = tvg.name; + // Create a comprehensive label: "EPG Name | TVG-ID | TVG Name" + let label; + if (epgSourceName && tvg.tvg_id) { + label = `${epgSourceName} | ${tvg.tvg_id}`; + if (tvgName && tvgName !== tvg.tvg_id) { + label += ` | ${tvgName}`; + } + } else if (tvgName) { + label = tvgName; + } else { + label = `ID: ${tvg.id}`; + } + + options.push({ + value: String(tvg.id), + label: label, + }); + }); + + return options; +}; + +export const getLogoOptions = (channelLogos) => { + const options = [ + { + value: 'null', + label: 'Default', + logo: null, + }, + ]; + + // Convert channelLogos object to array and sort by name + const logosArray = Object.values(channelLogos).sort((a, b) => + (a.name || '').localeCompare(b.name || '') + ); + + logosArray.forEach((logo) => { + options.push({ + value: String(logo.id), + label: logo.name || `Logo ${logo.id}`, + logo: logo, + }); + }); + + return options; +}; + +export const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; +export const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; +export const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; + +export const reorderChannel = (channelId, insertAfterId) => { + return API.reorderChannel(channelId, insertAfterId); +}; +export const deleteChannel = (id) => { + return API.deleteChannel(id); +}; +export const deleteChannels = (channelIds) => { + return API.deleteChannels(channelIds); +}; +export const queryChannels = (params) => { + return API.queryChannels(params); +}; +export const getAllChannelIds = (params) => { + return API.getAllChannelIds(params); +}; +export const updateProfileChannels = (channelIds, profileId, enabled) => { + return API.updateProfileChannels(channelIds, profileId, enabled); +}; +export const updateProfileChannel = (channelId, profileId, enabled) => { + return API.updateProfileChannel(channelId, profileId, enabled); +}; +export const addChannelProfile = (values) => { + return API.addChannelProfile(values); +}; +export const deleteChannelProfile = (id) => { + return API.deleteChannelProfile(id); +}; + +const getSortParam = (sorting) => { + let sortField = sorting[0].id; + // Map frontend column ids to backend ordering field names + const fieldMapping = { + channel_group: 'channel_group__name', + epg: 'epg_data__name', + }; + if (fieldMapping[sortField]) { + sortField = fieldMapping[sortField]; + } + const sortDirection = sorting[0].desc ? '-' : ''; + return { sortField, sortDirection }; +}; + +const applyDebouncedFilters = (debouncedFilters, params) => { + // Apply debounced filters + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (value) { + if (Array.isArray(value)) { + // Convert null values to "null" string for URL parameter + const processedValue = value + .map((v) => (v === null ? 'null' : v)) + .join(','); + params.append(key, processedValue); + } else { + params.append(key, value); + } + } + }); +}; + +// Build URLs with parameters +export const buildM3UUrl = (m3uParams, m3uUrl) => { + const params = new URLSearchParams(); + if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); + if (m3uParams.direct) params.append('direct', 'true'); + if (m3uParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', m3uParams.tvg_id_source); + if (m3uParams.output_format) + params.append('output_format', m3uParams.output_format); + if (m3uParams.output_profile) + params.append('output_profile', m3uParams.output_profile); + + const baseUrl = m3uUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildEPGUrl = (epgParams, epgUrl) => { + const params = new URLSearchParams(); + if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); + if (epgParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', epgParams.tvg_id_source); + if (epgParams.days > 0) params.append('days', epgParams.days.toString()); + if (epgParams.prev_days > 0) + params.append('prev_days', epgParams.prev_days.toString()); + + const baseUrl = epgUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildFetchParams = ({ + pagination, + sorting, + debouncedFilters, + selectedProfileId, + showDisabled, + showOnlyStreamlessChannels, + showOnlyStaleChannels, + showOnlyOverriddenChannels, + visibilityFilter, +}) => { + const params = new URLSearchParams(); + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + params.append('include_streams', 'true'); + + if (selectedProfileId !== '0') + params.append('channel_profile_id', selectedProfileId); + if (showDisabled) params.append('show_disabled', true); + if (showOnlyStreamlessChannels) params.append('only_streamless', true); + if (showOnlyStaleChannels) params.append('only_stale', true); + if (showOnlyOverriddenChannels) params.append('only_has_overrides', true); + if (visibilityFilter && visibilityFilter !== 'active') + params.append('visibility_filter', visibilityFilter); + if (sorting.length > 0) { + const { sortField, sortDirection } = getSortParam(sorting); + params.append('ordering', `${sortDirection}${sortField}`); + } + + applyDebouncedFilters(debouncedFilters, params); + return params; +}; + +export const buildHDHRUrl = (hdhrOutputProfileId, hdhrUrl) => { + if (!hdhrOutputProfileId) return hdhrUrl; + // Insert output_profile segment before the trailing slash (or at end) + const base = hdhrUrl.replace(/\/$/, ''); + return `${base}/output_profile/${hdhrOutputProfileId}`; +}; diff --git a/frontend/src/utils/tables/EPGsTableUtils.js b/frontend/src/utils/tables/EPGsTableUtils.js new file mode 100644 index 00000000..f409d1e2 --- /dev/null +++ b/frontend/src/utils/tables/EPGsTableUtils.js @@ -0,0 +1,55 @@ +import API from '../../api.js'; + +// Helper function to format status text +export const formatStatusText = (status) => { + if (!status) return 'Unknown'; + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +}; + +export const updateEpg = async (values, epg, isToggle) => { + return API.updateEPG({ ...values, id: epg.id }, isToggle); +}; +export const deleteEpg = (id) => { + return API.deleteEPG(id); +}; +export const refreshEpg = (id, force = false) => { + return API.refreshEPG(id, force); +}; + +export const getProgressLabel = (action) => { + switch (action) { + case 'downloading': + return 'Downloading'; + case 'extracting': + return 'Extracting'; + case 'parsing_channels': + return 'Parsing Channels'; + case 'parsing_programs': + return 'Parsing Programs'; + default: + return null; + } +}; + +export const getProgressInfo = (progress) => { + // Build additional info string from progress data + if (progress.message) { + return progress.message; + } else if ( + progress.processed !== undefined && + progress.channels !== undefined + ) { + return `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`; + } else if (progress.processed !== undefined && progress.total !== undefined) { + return `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`; + } + return null; +}; + +export const getSortedEpgs = (epgs, compareColumn, compareDesc) => { + return [...epgs].sort((a, b) => { + if (a[compareColumn] < b[compareColumn]) return compareDesc ? 1 : -1; + if (a[compareColumn] > b[compareColumn]) return compareDesc ? -1 : 1; + return 0; + }); +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/LogosTableUtils.js b/frontend/src/utils/tables/LogosTableUtils.js new file mode 100644 index 00000000..b80e0a32 --- /dev/null +++ b/frontend/src/utils/tables/LogosTableUtils.js @@ -0,0 +1,62 @@ +import API from '../../api.js'; + +export const getFilteredLogos = (logos, debouncedNameFilter, filtersUsed) => { + // Apply filters + let filteredLogos = Object.values(logos || {}); + + if (debouncedNameFilter) { + filteredLogos = filteredLogos.filter((logo) => + logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) + ); + } + + if (filtersUsed === 'used') { + filteredLogos = filteredLogos.filter((logo) => logo.is_used); + } else if (filtersUsed === 'unused') { + filteredLogos = filteredLogos.filter((logo) => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); +}; + +export const deleteLogo = (id, deleteFile) => { + return API.deleteLogo(id, deleteFile); +}; +export const deleteLogos = (ids, deleteFiles) => { + return API.deleteLogos(ids, deleteFiles); +}; +export const cleanupUnusedLogos = (deleteFiles) => { + return API.cleanupUnusedLogos(deleteFiles); +}; + +// Generate smart label based on usage +const categorizeUsage = (names) => { + const types = { channels: 0, movies: 0, series: 0 }; + + names.forEach((name) => { + if (name.startsWith('Channel:')) types.channels++; + else if (name.startsWith('Movie:')) types.movies++; + else if (name.startsWith('Series:')) types.series++; + }); + + return types; +}; + +// Analyze channel_names to categorize types +export const generateUsageLabel = (channelNames, channelCount) => { + const types = categorizeUsage(channelNames); + const typeCount = Object.values(types).filter( + (count) => count > 0 + ).length; + if (typeCount === 1) { + // Only one type - be specific + if (types.channels > 0) + return `${types.channels} ${types.channels !== 1 ? 'channels' : 'channel'}`; + if (types.movies > 0) + return `${types.movies} ${types.movies !== 1 ? 'movies' : 'movie'}`; + if (types.series > 0) return `${types.series} series`; + } else { + // Multiple types - use generic "items" + return `${channelCount} ${channelCount !== 1 ? 'items' : 'item'}`; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/M3UsTableUtils.js b/frontend/src/utils/tables/M3UsTableUtils.js new file mode 100644 index 00000000..9416d13f --- /dev/null +++ b/frontend/src/utils/tables/M3UsTableUtils.js @@ -0,0 +1,142 @@ +import API from '../../api.js'; +import { format } from '../dateTimeUtils.js'; +import { formatDuration } from '../dateTimeUtils.js'; +import { formatSpeed } from '../networkUtils.js'; + +export const refreshPlaylist = (id) => API.refreshPlaylist(id); + +export const getPlaylistAutoCreatedChannelsCount = (id) => + API.getPlaylistAutoCreatedChannelsCount(id); + +export const deletePlaylist = (id) => API.deletePlaylist(id); + +export const updatePlaylist = (values, playlist, isToggle = false) => + API.updatePlaylist({ ...values, id: playlist.id }, isToggle); + +export const formatStatusText = (status) => { + switch (status) { + case 'idle': return 'Idle'; + case 'fetching': return 'Fetching'; + case 'parsing': return 'Parsing'; + case 'error': return 'Error'; + case 'success': return 'Success'; + case 'pending_setup': return 'Pending Setup'; + default: + return status + ? status.charAt(0).toUpperCase() + status.slice(1) + : 'Unknown'; + } +}; + +export const getStatusColor = (status) => { + switch (status) { + case 'idle': return 'gray.5'; + case 'fetching': return 'blue.5'; + case 'parsing': return 'indigo.5'; + case 'error': return 'red.5'; + case 'success': return 'green.5'; + case 'pending_setup': return 'orange.5'; + default: return 'gray.5'; + } +}; + +export const getExpirationInfo = (daysLeft, earliest, fullDateFormat) => { + let color; + let label; + if (daysLeft < 0) { + color = 'red.7'; + label = 'Expired'; + } else if (daysLeft === 0) { + color = 'red.5'; + label = 'Expires today'; + } else if (daysLeft <= 7) { + color = 'orange.5'; + label = `${daysLeft}d left`; + } else if (daysLeft <= 30) { + color = 'yellow.5'; + label = `${daysLeft}d left`; + } else { + label = format(earliest, fullDateFormat); + } + return { color, label }; +}; + +export const getExpirationTooltip = (allExpirations, fullDateTimeFormat, label) => { + return allExpirations.length > 0 + ? allExpirations + .map( + (e) => + `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${ + !e.is_active ? ' (inactive)' : '' + }` + ) + .join('\n') + : label; +}; + +export const getSortedPlaylists = (playlists, compareColumn, compareDesc) => { + return playlists + .filter((playlist) => playlist.locked === false) + .sort((a, b) => { + const aVal = a[compareColumn]; + const bVal = b[compareColumn]; + + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + const comparison = + typeof aVal === 'string' + ? aVal.localeCompare(bVal) + : aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + + return compareDesc ? -comparison : comparison; + }); +}; + +export const getStatusContent = (data) => { + if (data.progress === 100) return null; + + switch (data.action) { + case 'initializing': + return { type: 'initializing' }; + + case 'downloading': + if (data.progress === 0) return { type: 'simple', label: 'Downloading...' }; + return { + type: 'downloading', + progress: parseInt(data.progress), + speed: formatSpeed(data.speed), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + }; + + case 'processing_groups': + if (data.progress === 0) return { type: 'simple', label: 'Processing groups...' }; + return { + type: 'groups', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + groupsProcessed: data.groups_processed, + }; + + case 'parsing': + if (data.progress === 0) return { type: 'simple', label: 'Parsing...' }; + return { + type: 'parsing', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + streamsProcessed: data.streams_processed, + }; + + default: + return data.status === 'error' + ? { type: 'error', error: data.error } + : { type: 'simple', label: `${data.action || 'Processing'}...` }; + } +}; + diff --git a/frontend/src/utils/tables/StreamsTableUtils.js b/frontend/src/utils/tables/StreamsTableUtils.js new file mode 100644 index 00000000..238446dc --- /dev/null +++ b/frontend/src/utils/tables/StreamsTableUtils.js @@ -0,0 +1,127 @@ +import API from '../../api.js'; + +export const addStreamsToChannel = (channelId, existingStreams, newStreams) => { + return API.addStreamsToChannel(channelId, existingStreams, newStreams); +}; +export const queryStreamsTable = (params) => { + return API.queryStreamsTable(params); +}; +export const getStreams = (streamIds) => { + return API.getStreams(streamIds); +}; +export const createChannelsFromStreamsAsync = ( + streamIds, + channelProfileIds, + startingChannelNumber +) => + API.createChannelsFromStreamsAsync( + streamIds, + channelProfileIds, + startingChannelNumber + ); +export const deleteStream = (id) => API.deleteStream(id); +export const deleteStreams = (ids) => { + return API.deleteStreams(ids); +}; +export const requeryStreams = () => { + return API.requeryStreams(); +}; +export const createChannelFromStream = (values) => + API.createChannelFromStream(values); +export const getAllStreamIds = (params) => { + return API.getAllStreamIds(params); +}; +export const getStreamFilterOptions = (params) => { + return API.getStreamFilterOptions(params); +}; + +export const getStatsTooltip = (stats) => { + // Build compact display (resolution + video codec) + const parts = []; + if (stats.resolution) { + // Convert "1920x1080" to "1080p" format + const height = stats.resolution.split('x')[1]; + if (height) parts.push(`${height}p`); + } + if (stats.video_codec) { + parts.push(stats.video_codec.toUpperCase()); + } + const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; + + // Build tooltip content with friendly labels + const tooltipLines = []; + if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`); + if (stats.video_codec) + tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`); + if (stats.video_bitrate) + tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); + if (stats.source_fps) + tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); + if (stats.audio_codec) + tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`); + if (stats.audio_channels) + tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); + if (stats.audio_bitrate) + tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); + + const tooltipContent = + tooltipLines.length > 0 + ? tooltipLines.join('\n') + : 'No source info available'; + return { compactDisplay, tooltipContent }; +}; + +export const appendFetchPageParams = (params, pagination, sorting) => { + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + + if (sorting.length > 0) { + const columnId = sorting[0].id; + const fieldMapping = { + name: 'name', + group: 'channel_group__name', + m3u: 'm3u_account__name', + tvg_id: 'tvg_id', + }; + const sortField = fieldMapping[columnId] || columnId; + const sortDirection = sorting[0].desc ? '-' : ''; + params.append('ordering', `${sortDirection}${sortField}`); + } +}; + +export const getChannelProfileIds = (profileIds, selectedProfileId) => { + // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise + if (profileIds) { + if (profileIds.includes('none')) { + return []; + } else if (profileIds.includes('all')) { + return null; + } else { + return profileIds.map((id) => parseInt(id)); + } + } else { + return selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; + } +}; + +export const getChannelNumberValue = (mode, startNumber) => { + return mode === 'provider' + ? null + : mode === 'auto' + ? 0 + : mode === 'highest' + ? -1 + : Number(startNumber); +}; + +export const getFilterParams = (debouncedFilters) => { + const params = new URLSearchParams(); + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (typeof value === 'boolean') { + if (value) params.append(key, 'true'); + } else if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } + }); + return params; +}; From 7a4db314603fafaaeb8f55a92ca1676a250e8712 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:21:51 -0700 Subject: [PATCH 02/10] Moved functionality to shared util --- .../src/utils/cards/VodConnectionCardUtils.js | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index 5bec7d9a..2514571a 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -1,27 +1,5 @@ import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js'; -export const formatDuration = (seconds) => { - if (!seconds) return 'Unknown'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; -}; - -// Format time for display (e.g., "1:23:45" or "23:45") -export const formatTime = (seconds) => { - if (!seconds || seconds === 0) return '0:00'; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } else { - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } -}; - export const getMovieDisplayTitle = (vodContent) => { return vodContent.content_name; }; From 1a46ca4be449c3a2c86ad19eca1ff593ffa1db9a Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:22:44 -0700 Subject: [PATCH 03/10] Added tests for utils --- .../tables/__tests__/M3uTableUtils.test.jsx | 229 +++++++ .../src/utils/__tests__/dateTimeUtils.test.js | 108 ++++ .../__tests__/VodConnectionCardUtils.test.js | 80 +-- .../utils/tables/ChannelTableStreamsUtils.js | 100 +++ .../utils/tables/OutputProfilesTableUtils.js | 6 + .../ChannelTableStreamsUtils.test.js | 288 +++++++++ .../__tests__/ChannelsTableUtils.test.js | 611 ++++++++++++++++++ .../tables/__tests__/EPGsTableUtils.test.js | 238 +++++++ .../tables/__tests__/LogosTableUtils.test.js | 220 +++++++ .../tables/__tests__/M3UsTableUtils.test.js | 474 ++++++++++++++ .../OutputProfilesTableUtils.test.js | 55 ++ .../__tests__/StreamsTableUtils.test.js | 422 ++++++++++++ 12 files changed, 2756 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx create mode 100644 frontend/src/utils/tables/ChannelTableStreamsUtils.js create mode 100644 frontend/src/utils/tables/OutputProfilesTableUtils.js create mode 100644 frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/LogosTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js diff --git a/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx new file mode 100644 index 00000000..7ad3a002 --- /dev/null +++ b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from '../M3uTableUtils'; + +vi.mock('@mantine/core', () => ({ + Center: ({ children }) =>