diff --git a/CHANGELOG.md b/CHANGELOG.md index 9535a74e..d6c94a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,23 +30,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +- Region code options now intentionally include both `GB` (ISO 3166-1 standard) and `UK` (commonly used by EPG/XMLTV providers) to accommodate real-world EPG data variations. Many providers use `UK` in channel identifiers (e.g., `BBCOne.uk`) despite `GB` being the official ISO country code. Users should select the region code that matches their specific EPG provider's convention for optimal region-based EPG matching bonuses - Thanks [@bigpandaaaa](https://github.com/bigpandaaaa) - Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database - Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed) - Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink) - Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810) - - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis) - - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils) - - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal) with loading fallbacks + - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field) + - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils) + - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks - Removed unused Dashboard and Home pages + - Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization + - Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic - Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements - M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs +- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) + - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files + - Moved larger nested components into their own files + - Moved business logic into corresponding utils/ files + - Extracted larger in-line component logic into its own function + - Each panel in Settings now uses its own form state with the parent component handling active state management ### Fixed +- Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets `force_dummy_epg` flag to prevent unwanted EPG assignment. (Fixes #788) +- VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770) +- VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736) +- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) - M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704) - M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) - Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect - XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) +- Docker container file permissions: Django management commands (`migrate`, `collectstatic`) now run as the non-root user to prevent root-owned `__pycache__` and static files from causing permission issues - Thanks [@sethwv](https://github.com/sethwv) - Stream validation now continues with GET request if HEAD request fails due to connection issues - Thanks [@kvnnap](https://github.com/kvnnap) (Fixes #782) - XtreamCodes M3U files now correctly set `x-tvg-url` and `url-tvg` headers to reference XC EPG URL (`xmltv.php`) instead of standard EPG endpoint when downloaded via XC API (Fixes #629) diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 8cc55a11..3bd984e6 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): if category: if '|' in category: - cat_name, cat_type = category.split('|', 1) + cat_name, cat_type = category.rsplit('|', 1) if cat_type == 'movie': where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" where_conditions[1] = "1=0" # Exclude series diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index d42be946..4eb9fadc 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1292,8 +1292,17 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) try: episode_id = str(episode_data.get('id')) episode_name = episode_data.get('title', 'Unknown Episode') - season_number = episode_data['_season_number'] - episode_number = episode_data.get('episode_num', 0) + # Ensure season and episode numbers are integers (API may return strings) + try: + season_number = int(episode_data['_season_number']) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}") + season_number = 0 + try: + episode_number = int(episode_data.get('episode_num', 0)) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}") + episode_number = 0 info = episode_data.get('info', {}) # Extract episode metadata @@ -1324,7 +1333,7 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Check if we already have this episode pending creation (multiple streams for same episode) if not episode and episode_key in episodes_pending_creation: episode = episodes_pending_creation[episode_key] - logger.debug(f"Reusing pending episode for S{season_number:02d}E{episode_number:02d} (stream_id: {episode_id})") + logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})") if episode: # Update existing episode @@ -1432,6 +1441,21 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) if key in episode_pk_map: relation.episode = episode_pk_map[key] + # Filter out relations with unsaved episodes (no PK) + # This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode + valid_relations_to_create = [] + for relation in relations_to_create: + if relation.episode.pk is not None: + valid_relations_to_create.append(relation) + else: + season_num = relation.episode.season_number + episode_num = relation.episode.episode_number + logger.warning( + f"Skipping relation for episode S{season_num}E{episode_num} " + f"- episode not saved to database" + ) + relations_to_create = valid_relations_to_create + # Update existing episodes if episodes_to_update: Episode.objects.bulk_update(episodes_to_update, [ diff --git a/docker/Dockerfile b/docker/Dockerfile index dc437227..bfb35c11 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,9 +35,6 @@ RUN rm -rf /app/frontend # Copy built frontend assets COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist -# Run Django collectstatic -RUN python manage.py collectstatic --noinput - # Add timestamp argument ARG TIMESTAMP diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 72eb5928..5de9bf0a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -100,7 +100,7 @@ export POSTGRES_DIR=/data/db if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then # Define all variables to process variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT @@ -174,9 +174,9 @@ else pids+=("$nginx_pid") fi -cd /app -python manage.py migrate --noinput -python manage.py collectstatic --noinput +# Run Django commands as non-root user to prevent permission issues +su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput" +su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput" # Select proper uwsgi config based on environment if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 03fe6816..0c317017 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -15,6 +15,7 @@ DATA_DIRS=( APP_DIRS=( "/app/logo_cache" "/app/media" + "/app/static" ) # Create all directories diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 00000000..1293bf7b --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,47 @@ +import { NumberInput, Select, Switch, TextInput } from '@mantine/core'; +import React from 'react'; + +export const Field = ({ field, value, onChange }) => { + const common = { label: field.label, description: field.help_text }; + const effective = value ?? field.default; + switch (field.type) { + case 'boolean': + return ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={field.help_text} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + {...common} + /> + ); + case 'select': + return ( + ({ + value: `${option.id}`, + label: option.name, + }))} + /> + ({ + label: r.label, + value: `${r.value}`, + }))} + /> + + + + Auto-Import Mapped Files + + + + + + + {rehashSuccess && ( + + )} + + + + + + + + { + setRehashConfirmOpen(false); + setRehashDialogType(null); + // Clear pending values when dialog is cancelled + setPendingChangedSettings(null); + }} + onConfirm={handleRehashConfirm} + title={ + rehashDialogType === 'save' + ? 'Save Settings and Rehash Streams' + : 'Confirm Stream Rehash' + } + message={ +
+ {`Are you sure you want to rehash all streams? + +This process may take a while depending on the number of streams. +Do not shut down Dispatcharr until the rehashing is complete. +M3U refreshes will be blocked until this process finishes. + +Please ensure you have time to let this complete before proceeding.`} +
+ } + confirmLabel={ + rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash' + } + cancelLabel="Cancel" + actionKey="rehash-streams" + onSuppressChange={suppressWarning} + size="md" + /> + + ); +}); + +export default StreamSettingsForm; \ No newline at end of file diff --git a/frontend/src/components/forms/settings/SystemSettingsForm.jsx b/frontend/src/components/forms/settings/SystemSettingsForm.jsx new file mode 100644 index 00000000..3f05c564 --- /dev/null +++ b/frontend/src/components/forms/settings/SystemSettingsForm.jsx @@ -0,0 +1,84 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { getSystemSettingsFormInitialValues } from '../../../utils/forms/settings/SystemSettingsFormUtils.js'; + +const SystemSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + + const [saved, setSaved] = useState(false); + + const form = useForm({ + mode: 'controlled', + initialValues: getSystemSettingsFormInitialValues(), + }); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const formValues = parseSettings(settings); + + form.setValues(formValues); + } + }, [settings]); + + const onSubmit = async () => { + setSaved(false); + + const changedSettings = getChangedSettings(form.getValues(), settings); + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + } + }; + + return ( + + {saved && ( + + )} + + Configure how many system events (channel start/stop, buffering, etc.) + to keep in the database. Events are displayed on the Stats page. + + { + form.setFieldValue('max-system-events', value); + }} + min={10} + max={1000} + step={10} + /> + + + + + ); +}); + +export default SystemSettingsForm; \ No newline at end of file diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx new file mode 100644 index 00000000..c0f7b354 --- /dev/null +++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx @@ -0,0 +1,144 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import useLocalStorage from '../../../hooks/useLocalStorage.jsx'; +import { + buildTimeZoneOptions, + getDefaultTimeZone, +} from '../../../utils/dateTimeUtils.js'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { Select } from '@mantine/core'; +import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js'; + +const UiSettingsForm = React.memo(() => { + const settings = useSettingsStore((s) => s.settings); + + const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); + const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h'); + const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy'); + const [timeZone, setTimeZone] = useLocalStorage( + 'time-zone', + getDefaultTimeZone() + ); + + const timeZoneOptions = useMemo( + () => buildTimeZoneOptions(timeZone), + [timeZone] + ); + + const timeZoneSyncedRef = useRef(false); + + const persistTimeZoneSetting = useCallback( + async (tzValue) => { + try { + await saveTimeZoneSetting(tzValue, settings); + } catch (error) { + console.error('Failed to persist time zone setting', error); + showNotification({ + title: 'Failed to update time zone', + message: 'Could not save the selected time zone. Please try again.', + color: 'red', + }); + } + }, + [settings] + ); + + useEffect(() => { + if (settings) { + const tzSetting = settings['system-time-zone']; + if (tzSetting?.value) { + timeZoneSyncedRef.current = true; + setTimeZone((prev) => + prev === tzSetting.value ? prev : tzSetting.value + ); + } else if (!timeZoneSyncedRef.current && timeZone) { + timeZoneSyncedRef.current = true; + persistTimeZoneSetting(timeZone); + } + } + }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]); + + const onUISettingsChange = (name, value) => { + switch (name) { + case 'table-size': + if (value) setTableSize(value); + break; + case 'time-format': + if (value) setTimeFormat(value); + break; + case 'date-format': + if (value) setDateFormat(value); + break; + case 'time-zone': + if (value) { + setTimeZone(value); + persistTimeZoneSetting(value); + } + break; + } + }; + + return ( + <> + onUISettingsChange('time-format', val)} + data={[ + { + value: '12h', + label: '12 hour time', + }, + { + value: '24h', + label: '24 hour time', + }, + ]} + /> + onUISettingsChange('time-zone', val)} + data={timeZoneOptions} + /> + + ); +}); + +export default UiSettingsForm; \ No newline at end of file diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 78f374d4..528c5f04 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -303,6 +303,7 @@ export const REGION_CHOICES = [ { value: 'tz', label: 'TZ' }, { value: 'ua', label: 'UA' }, { value: 'ug', label: 'UG' }, + { value: 'uk', label: 'UK' }, { value: 'um', label: 'UM' }, { value: 'us', label: 'US' }, { value: 'uy', label: 'UY' }, diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 0fe4f7a7..b7b87b17 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -65,6 +65,7 @@ const PageContent = () => { if (!authUser.id) return <>; if (authUser.user_level <= USER_LEVELS.STANDARD) { + handleStreamsReady(); return ( diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 8e39cf2c..7bd6e07f 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -18,15 +18,29 @@ import useSettingsStore from '../store/settings'; import useVideoStore from '../store/useVideoStore'; import RecordingForm from '../components/forms/Recording'; import { + isAfter, + isBefore, useTimeHelpers, } from '../utils/dateTimeUtils.js'; -const RecordingDetailsModal = lazy(() => import('../components/forms/RecordingDetailsModal')); +const RecordingDetailsModal = lazy(() => + import('../components/forms/RecordingDetailsModal')); import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx'; import RecordingCard from '../components/cards/RecordingCard.jsx'; import { categorizeRecordings } from '../utils/pages/DVRUtils.js'; -import { getPosterUrl } from '../utils/cards/RecordingCardUtils.js'; +import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; +const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => { + return list.map((rec) => ( + + )); +}; + const DVRPage = () => { const theme = useMantineTheme(); const recordings = useChannelsStore((s) => s.recordings); @@ -94,46 +108,25 @@ const DVRPage = () => { return categorizeRecordings(recordings, toUserTime, now); }, [recordings, now, toUserTime]); - const RecordingList = ({ list }) => { - return list.map((rec) => ( - - )); - }; - const handleOnWatchLive = () => { const rec = detailsRecording; const now = userNow(); const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if(isAfter(now, s) && isBefore(now, e)) { // call into child RecordingCard behavior by constructing a URL like there const channel = channels[rec.channel]; if (!channel) return; - let url = `/proxy/ts/stream/${channel.uuid}`; - if (useSettingsStore.getState().environment.env_mode === 'dev') { - url = `${window.location.protocol}//${window.location.hostname}:5656${url}`; - } + const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode); useVideoStore.getState().showVideo(url, 'live'); } } const handleOnWatchRecording = () => { - let fileUrl = - detailsRecording.custom_properties?.file_url || - detailsRecording.custom_properties?.output_file_url; - if (!fileUrl) return; - if ( - useSettingsStore.getState().environment.env_mode === 'dev' && - fileUrl.startsWith('/') - ) { - fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`; - } - useVideoStore.getState().showVideo(fileUrl, 'vod', { + const url = getRecordingUrl( + detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode); + if(!url) return; + useVideoStore.getState().showVideo(url, 'vod', { name: detailsRecording.custom_properties?.program?.title || 'Recording', @@ -163,7 +156,7 @@ const DVRPage = () => { > New Recording - +
Currently Recording @@ -177,7 +170,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {inProgress.length === 0 && ( Nothing recording right now. @@ -199,7 +196,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {upcoming.length === 0 && ( No upcoming recordings. @@ -221,7 +222,11 @@ const DVRPage = () => { { maxWidth: '36rem', cols: 1 }, ]} > - {} + {} {completed.length === 0 && ( No completed recordings yet. diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index dbeaf431..ac0fdf82 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -5,248 +5,94 @@ import React, { useEffect, useRef, useCallback, + Suspense, } from 'react'; -import dayjs from 'dayjs'; -import API from '../api'; import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; -import logo from '../images/logo.png'; import useVideoStore from '../store/useVideoStore'; // NEW import -import { notifications } from '@mantine/notifications'; import useSettingsStore from '../store/settings'; import { - Title, - Box, - Flex, - Button, - Text, - Paper, - Group, - TextInput, - Select, ActionIcon, + Box, + Button, + Flex, + Group, + LoadingOverlay, + Paper, + Select, + Text, + TextInput, + Title, Tooltip, - Transition, - Modal, - Stack, } from '@mantine/core'; -import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react'; +import { Calendar, Clock, Search, Video, X } from 'lucide-react'; import './guide.css'; import useEPGsStore from '../store/epgs'; -import useLocalStorage from '../hooks/useLocalStorage'; import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { - PROGRAM_HEIGHT, - EXPANDED_PROGRAM_HEIGHT, buildChannelIdMap, - mapProgramsByChannel, + calculateDesiredScrollPosition, + calculateEarliestProgramStart, + calculateEnd, + calculateHourTimeline, + calculateLatestProgramEnd, + calculateLeftScrollPosition, + calculateNowPosition, + calculateScrollPosition, + calculateScrollPositionByTimeClick, + calculateStart, + CHANNEL_WIDTH, computeRowHeights, + createRecording, + createSeriesRule, + evaluateSeriesRule, + EXPANDED_PROGRAM_HEIGHT, + fetchPrograms, + fetchRules, + filterGuideChannels, + formatTime, + getGroupOptions, + getProfileOptions, + getRuleByProgram, + HOUR_WIDTH, + mapChannelsById, + mapProgramsByChannel, + mapRecordingsByProgramId, + matchChannelByTvgId, + MINUTE_BLOCK_WIDTH, + MINUTE_INCREMENT, + PROGRAM_HEIGHT, + sortChannels, } from './guideUtils'; - -/** Layout constants */ -const CHANNEL_WIDTH = 120; // Width of the channel/logo column -const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider -const MINUTE_INCREMENT = 15; // For positioning programs every 15 min -const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); - -const GuideRow = React.memo(({ index, style, data }) => { - const { - filteredChannels, - programsByChannelId, - expandedProgramId, - rowHeights, - logos, - hoveredChannelId, - setHoveredChannelId, - renderProgram, - handleLogoClick, - contentWidth, - } = data; - - const channel = filteredChannels[index]; - if (!channel) { - return null; - } - - const channelPrograms = programsByChannelId.get(channel.id) || []; - const rowHeight = - rowHeights[index] ?? - (channelPrograms.some((program) => program.id === expandedProgramId) - ? EXPANDED_PROGRAM_HEIGHT - : PROGRAM_HEIGHT); - - return ( -
- - handleLogoClick(channel, event)} - onMouseEnter={() => setHoveredChannelId(channel.id)} - onMouseLeave={() => setHoveredChannelId(null)} - > - {hoveredChannelId === channel.id && ( - - - - )} - - - - {channel.name} - - - - {channel.channel_number || '-'} - - - - - - {channelPrograms.length > 0 ? ( - channelPrograms.map((program) => - renderProgram(program, undefined, channel) - ) - ) : ( - <> - {Array.from({ length: Math.ceil(24 / 2) }).map( - (_, placeholderIndex) => ( - - No program data - - ) - )} - - )} - - -
- ); -}); +import { + getShowVideoUrl, +} from '../utils/cards/RecordingCardUtils.js'; +import { + add, + convertToMs, + format, + getNow, + initializeTime, + startOfDay, + useDateTimeFormat, +} from '../utils/dateTimeUtils.js'; +import GuideRow from '../components/GuideRow.jsx'; +import HourTimeline from '../components/HourTimeline'; +const ProgramRecordingModal = React.lazy(() => + import('../components/forms/ProgramRecordingModal')); +const SeriesRecordingModal = React.lazy(() => + import('../components/forms/SeriesRecordingModal')); +import { showNotification } from '../utils/notificationUtils.js'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; export default function TVChannelGuide({ startDate, endDate }) { const channels = useChannelsStore((s) => s.channels); const recordings = useChannelsStore((s) => s.recordings); const channelGroups = useChannelsStore((s) => s.channelGroups); const profiles = useChannelsStore((s) => s.profiles); + const isLoading = useChannelsStore((s) => s.isLoading); + const [isProgramsLoading, setIsProgramsLoading] = useState(true); const logos = useLogosStore((s) => s.logos); const tvgsById = useEPGsStore((s) => s.tvgsById); @@ -254,8 +100,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const [programs, setPrograms] = useState([]); const [guideChannels, setGuideChannels] = useState([]); - const [filteredChannels, setFilteredChannels] = useState([]); - const [now, setNow] = useState(dayjs()); + const [now, setNow] = useState(getNow()); const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program const [recordingForProgram, setRecordingForProgram] = useState(null); const [recordChoiceOpen, setRecordChoiceOpen] = useState(false); @@ -290,81 +135,38 @@ export default function TVChannelGuide({ startDate, endDate }) { // Load program data once useEffect(() => { - if (!Object.keys(channels).length === 0) { + if (Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); - notifications.show({ title: 'No channels available', color: 'red.5' }); + showNotification({ title: 'No channels available', color: 'red.5' }); + setIsProgramsLoading(false); return; } - const fetchPrograms = async () => { - console.log('Fetching program grid...'); - const fetched = await API.getGrid(); // GETs your EPG grid - console.log(`Received ${fetched.length} programs`); + const sortedChannels = sortChannels(channels); + setGuideChannels(sortedChannels); - // 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`); - - const processedPrograms = fetched.map((program) => { - const start = dayjs(program.start_time); - const end = dayjs(program.end_time); - return { - ...program, - startMs: start.valueOf(), - endMs: end.valueOf(), - }; + fetchPrograms() + .then((data) => { + setPrograms(data); + setIsProgramsLoading(false); + }) + .catch((error) => { + console.error('Failed to fetch programs:', error); + setIsProgramsLoading(false); }); - - setGuideChannels(sortedChannels); - setFilteredChannels(sortedChannels); // Initialize filtered channels - setPrograms(processedPrograms); - }; - - fetchPrograms(); }, [channels]); // Apply filters when search, group, or profile changes - useEffect(() => { - if (!guideChannels.length) return; + const filteredChannels = useMemo(() => { + if (!guideChannels.length) return []; - let result = [...guideChannels]; - - // Apply search filter - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter((channel) => - channel.name.toLowerCase().includes(query) - ); - } - - // Apply channel group filter - if (selectedGroupId !== 'all') { - result = result.filter( - (channel) => channel.channel_group_id === parseInt(selectedGroupId) - ); - } - - // Apply profile filter - if (selectedProfileId !== 'all') { - // Get the profile's enabled channels - const profileChannels = profiles[selectedProfileId]?.channels || []; - // Check if channels is a Set (from the error message, it likely is) - const enabledChannelIds = Array.isArray(profileChannels) - ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id) - : profiles[selectedProfileId]?.channels instanceof Set - ? Array.from(profiles[selectedProfileId].channels) - : []; - - result = result.filter((channel) => - enabledChannelIds.includes(channel.id) - ); - } - - setFilteredChannels(result); + return filterGuideChannels( + guideChannels, + searchQuery, + selectedGroupId, + selectedProfileId, + profiles + ); }, [ searchQuery, selectedGroupId, @@ -374,61 +176,44 @@ export default function TVChannelGuide({ startDate, endDate }) { ]); // Use start/end from props or default to "today at midnight" +24h - const defaultStart = dayjs(startDate || dayjs().startOf('day')); - const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour'); + const defaultStart = initializeTime(startDate || startOfDay(getNow())); + const defaultEnd = endDate + ? initializeTime(endDate) + : add(defaultStart, 24, 'hour'); // Expand timeline if needed based on actual earliest/ latest program - const earliestProgramStart = useMemo(() => { - if (!programs.length) return defaultStart; - return programs.reduce((acc, p) => { - const s = dayjs(p.start_time); - return s.isBefore(acc) ? s : acc; - }, defaultStart); - }, [programs, defaultStart]); + const earliestProgramStart = useMemo( + () => calculateEarliestProgramStart(programs, defaultStart), + [programs, defaultStart] + ); - const latestProgramEnd = useMemo(() => { - if (!programs.length) return defaultEnd; - return programs.reduce((acc, p) => { - const e = dayjs(p.end_time); - return e.isAfter(acc) ? e : acc; - }, defaultEnd); - }, [programs, defaultEnd]); + const latestProgramEnd = useMemo( + () => calculateLatestProgramEnd(programs, defaultEnd), + [programs, defaultEnd] + ); - const start = earliestProgramStart.isBefore(defaultStart) - ? earliestProgramStart - : defaultStart; - const end = latestProgramEnd.isAfter(defaultEnd) - ? latestProgramEnd - : defaultEnd; + const start = calculateStart(earliestProgramStart, defaultStart); + const end = calculateEnd(latestProgramEnd, defaultEnd); const channelIdByTvgId = useMemo( () => buildChannelIdMap(guideChannels, tvgsById, epgs), [guideChannels, tvgsById, epgs] ); - const channelById = useMemo(() => { - const map = new Map(); - guideChannels.forEach((channel) => { - map.set(channel.id, channel); - }); - return map; - }, [guideChannels]); + const channelById = useMemo( + () => mapChannelsById(guideChannels), + [guideChannels] + ); const programsByChannelId = useMemo( () => mapProgramsByChannel(programs, channelIdByTvgId), [programs, channelIdByTvgId] ); - const recordingsByProgramId = useMemo(() => { - const map = new Map(); - (recordings || []).forEach((recording) => { - const programId = recording?.custom_properties?.program?.id; - if (programId != null) { - map.set(programId, recording); - } - }); - return map; - }, [recordings]); + const recordingsByProgramId = useMemo( + () => mapRecordingsByProgramId(recordings), + [recordings] + ); const rowHeights = useMemo( () => @@ -445,62 +230,19 @@ export default function TVChannelGuide({ startDate, endDate }) { [rowHeights] ); - const [timeFormatSetting] = useLocalStorage('time-format', '12h'); - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - // Use user preference for time format - const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm'; - const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM'; + const [timeFormat, dateFormat] = useDateTimeFormat(); // Format day label using relative terms when possible (Today, Tomorrow, etc) const formatDayLabel = useCallback( - (time) => { - const today = dayjs().startOf('day'); - const tomorrow = today.add(1, 'day'); - const weekLater = today.add(7, 'day'); - - const day = time.startOf('day'); - - if (day.isSame(today, 'day')) { - return 'Today'; - } else if (day.isSame(tomorrow, 'day')) { - return 'Tomorrow'; - } else if (day.isBefore(weekLater)) { - // Within a week, show day name - return time.format('dddd'); - } else { - // Beyond a week, show month and day - return time.format(dateFormat); - } - }, + (time) => formatTime(time, dateFormat), [dateFormat] ); // Hourly marks with day labels - const hourTimeline = useMemo(() => { - const hours = []; - let current = start; - let currentDay = null; - - while (current.isBefore(end)) { - // Check if we're entering a new day - const day = current.startOf('day'); - const isNewDay = !currentDay || !day.isSame(currentDay, 'day'); - - if (isNewDay) { - currentDay = day; - } - - // Add day information to our hour object - hours.push({ - time: current, - isNewDay, - dayLabel: formatDayLabel(current), - }); - - current = current.add(1, 'hour'); - } - return hours; - }, [start, end, formatDayLabel]); + const hourTimeline = useMemo( + () => calculateHourTimeline(start, end, formatDayLabel), + [start, end, formatDayLabel] + ); useEffect(() => { const node = guideRef.current; @@ -542,17 +284,16 @@ export default function TVChannelGuide({ startDate, endDate }) { // Update "now" every second useEffect(() => { const interval = setInterval(() => { - setNow(dayjs()); + setNow(getNow()); }, 1000); return () => clearInterval(interval); }, []); // Pixel offset for the "now" vertical line - const nowPosition = useMemo(() => { - if (now.isBefore(start) || now.isAfter(end)) return -1; - const minutesSinceStart = now.diff(start, 'minute'); - return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - }, [now, start, end]); + const nowPosition = useMemo( + () => calculateNowPosition(now, start, end), + [now, start, end] + ); useEffect(() => { const tvGuide = tvGuideRef.current; @@ -765,31 +506,14 @@ export default function TVChannelGuide({ startDate, endDate }) { // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { if (programs.length > 0 && !initialScrollComplete) { - const roundedNow = - now.minute() < 30 - ? now.startOf('hour') - : now.startOf('hour').add(30, 'minute'); - const nowOffset = roundedNow.diff(start, 'minute'); - const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - - MINUTE_BLOCK_WIDTH; - - const scrollPos = Math.max(scrollPosition, 0); - syncScrollLeft(scrollPos); + syncScrollLeft(calculateScrollPosition(now, start)); setInitialScrollComplete(true); } }, [programs, start, now, initialScrollComplete, syncScrollLeft]); const findChannelByTvgId = useCallback( - (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; - }, + (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId), [channelById, channelIdByTvgId] ); @@ -798,19 +522,14 @@ export default function TVChannelGuide({ startDate, endDate }) { setRecordChoiceProgram(program); setRecordChoiceOpen(true); try { - const rules = await API.listSeriesRules(); - const rule = (rules || []).find( - (r) => - String(r.tvg_id) === String(program.tvg_id) && - (!r.title || r.title === program.title) - ); + const rules = await fetchRules(); + const rule = getRuleByProgram(rules, program); setExistingRuleMode(rule ? rule.mode : null); } catch (error) { console.warn('Failed to fetch series rules metadata', error); } - const existingRecording = recordingsByProgramId.get(program.id) || null; - setRecordingForProgram(existingRecording); + setRecordingForProgram(recordingsByProgramId.get(program.id) || null); }, [recordingsByProgramId] ); @@ -819,7 +538,7 @@ export default function TVChannelGuide({ startDate, endDate }) { async (program) => { const channel = findChannelByTvgId(program.tvg_id); if (!channel) { - notifications.show({ + showNotification({ title: 'Unable to schedule recording', message: 'No channel found for this program.', color: 'red.6', @@ -827,24 +546,15 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); - notifications.show({ title: 'Recording scheduled' }); + await createRecording(channel, program); + showNotification({ title: 'Recording scheduled' }); }, [findChannelByTvgId] ); const saveSeriesRule = useCallback(async (program, mode) => { - await API.createSeriesRule({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); - await API.evaluateSeriesRules(program.tvg_id); + await createSeriesRule(program, mode); + await evaluateSeriesRule(program); try { await useChannelsStore.getState().fetchRecordings(); } catch (error) { @@ -853,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) { error ); } - notifications.show({ + showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', }); }, []); @@ -861,7 +571,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const openRules = useCallback(async () => { setRulesOpen(true); try { - const r = await API.listSeriesRules(); + const r = await fetchRules(); setRules(r); } catch (error) { console.warn('Failed to load series rules', error); @@ -878,12 +588,7 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - let vidUrl = `/proxy/ts/stream/${matched.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - showVideo(vidUrl); + showVideo(getShowVideoUrl(matched, env_mode)); }, [env_mode, findChannelByTvgId, showVideo] ); @@ -892,12 +597,7 @@ export default function TVChannelGuide({ startDate, endDate }) { (channel, event) => { event.stopPropagation(); - let vidUrl = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - showVideo(vidUrl); + showVideo(getShowVideoUrl(channel, env_mode)); }, [env_mode, showVideo] ); @@ -906,13 +606,6 @@ export default function TVChannelGuide({ startDate, endDate }) { (program, event) => { event.stopPropagation(); - const programStartMs = - program.startMs ?? dayjs(program.start_time).valueOf(); - const startOffsetMinutes = (programStartMs - start.valueOf()) / 60000; - const leftPx = - (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - const desiredScrollPosition = Math.max(0, leftPx - 20); - if (expandedProgramId === program.id) { setExpandedProgramId(null); setRecordingForProgram(null); @@ -921,6 +614,9 @@ export default function TVChannelGuide({ startDate, endDate }) { setRecordingForProgram(recordingsByProgramId.get(program.id) || null); } + const leftPx = calculateLeftScrollPosition(program, start); + const desiredScrollPosition = calculateDesiredScrollPosition(leftPx); + const guideNode = guideRef.current; if (guideNode) { const currentScrollPosition = guideNode.scrollLeft; @@ -948,16 +644,7 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - const roundedNow = - now.minute() < 30 - ? now.startOf('hour') - : now.startOf('hour').add(30, 'minute'); - const nowOffset = roundedNow.diff(start, 'minute'); - const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; - - const scrollPos = Math.max(scrollPosition, 0); - syncScrollLeft(scrollPos, 'smooth'); + syncScrollLeft(calculateScrollPosition(now, start), 'smooth'); }, [now, nowPosition, start, syncScrollLeft]); const handleTimelineScroll = useCallback(() => { @@ -1000,44 +687,26 @@ export default function TVChannelGuide({ startDate, endDate }) { const handleTimeClick = useCallback( (clickedTime, event) => { - const rect = event.currentTarget.getBoundingClientRect(); - const clickPositionX = event.clientX - rect.left; - const percentageAcross = clickPositionX / rect.width; - const minuteWithinHour = Math.floor(percentageAcross * 60); - - let snappedMinute; - if (minuteWithinHour < 7.5) { - snappedMinute = 0; - } else if (minuteWithinHour < 22.5) { - snappedMinute = 15; - } else if (minuteWithinHour < 37.5) { - snappedMinute = 30; - } else if (minuteWithinHour < 52.5) { - snappedMinute = 45; - } else { - snappedMinute = 0; - clickedTime = clickedTime.add(1, 'hour'); - } - - const snappedTime = clickedTime.minute(snappedMinute); - const snappedOffset = snappedTime.diff(start, 'minute'); - const scrollPosition = - (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - - syncScrollLeft(scrollPosition, 'smooth'); + syncScrollLeft( + calculateScrollPositionByTimeClick(event, clickedTime, start), + 'smooth' + ); }, [start, syncScrollLeft] ); const renderProgram = useCallback( (program, channelStart = start, channel = null) => { - const programStartMs = - program.startMs ?? dayjs(program.start_time).valueOf(); - const programEndMs = program.endMs ?? dayjs(program.end_time).valueOf(); - const programStart = dayjs(programStartMs); - const programEnd = dayjs(programEndMs); + const { + programStart, + programEnd, + startMs: programStartMs, + endMs: programEndMs, + isLive, + isPast, + } = program; const startOffsetMinutes = - (programStartMs - channelStart.valueOf()) / 60000; + (programStartMs - convertToMs(channelStart)) / 60000; const durationMinutes = (programEndMs - programStartMs) / 60000; const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; @@ -1048,10 +717,7 @@ export default function TVChannelGuide({ startDate, endDate }) { const recording = recordingsByProgramId.get(program.id); - const isLive = now.isAfter(programStart) && now.isBefore(programEnd); - const isPast = now.isAfter(programEnd); const isExpanded = expandedProgramId === program.id; - const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; const MIN_EXPANDED_WIDTH = 450; const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); @@ -1069,36 +735,61 @@ export default function TVChannelGuide({ startDate, endDate }) { textOffsetLeft = Math.min(visibleStart, maxOffset); } + const RecordButton = () => { + return ( + + ); + }; + const WatchNow = () => { + return ( + + ); + }; return ( handleProgramClick(program, event)} > {recording && ( @@ -1154,8 +850,8 @@ export default function TVChannelGuide({ startDate, endDate }) { overflow: 'hidden', }} > - {programStart.format(timeFormat)} -{' '} - {programEnd.format(timeFormat)} + {format(programStart, timeFormat)} -{' '} + {format(programEnd, timeFormat)}
@@ -1169,13 +865,13 @@ export default function TVChannelGuide({ startDate, endDate }) { {program.description} @@ -1183,37 +879,11 @@ export default function TVChannelGuide({ startDate, endDate }) { )} {isExpanded && ( - + - {!isPast && ( - - )} + {!isPast && } - {isLive && ( - - )} + {isLive && } )} @@ -1296,49 +966,13 @@ export default function TVChannelGuide({ startDate, endDate }) { }, [searchQuery, selectedGroupId, selectedProfileId]); // Create group options for dropdown - but only include groups used by guide channels - const groupOptions = useMemo(() => { - 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; - }, [channelGroups, guideChannels]); + const groupOptions = useMemo( + () => getGroupOptions(channelGroups, guideChannels), + [channelGroups, guideChannels] + ); // Create profile options for dropdown - const profileOptions = useMemo(() => { - 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; - }, [profiles]); + const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]); // Clear all filters const clearFilters = () => { @@ -1357,40 +991,45 @@ export default function TVChannelGuide({ startDate, endDate }) { setSelectedProfileId(value || 'all'); }; + const handleClearSearchQuery = () => { + setSearchQuery(''); + }; + const handleChangeSearchQuery = (e) => { + setSearchQuery(e.target.value); + }; + return ( {/* Sticky top bar */} {/* Title and current time */} - + <Title order={3} fw={'bold'}> TV Guide - {now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)} + {format(now, `dddd, ${dateFormat}, YYYY • ${timeFormat}`)} setSearchQuery(e.target.value)} - style={{ width: '250px' }} // Reduced width from flex: 1 + onChange={handleChangeSearchQuery} + w={'250px'} // Reduced width from flex: 1 leftSection={} rightSection={ searchQuery ? ( setSearchQuery('')} + onClick={handleClearSearchQuery} variant="subtle" color="gray" size="sm" @@ -1433,7 +1072,7 @@ export default function TVChannelGuide({ startDate, endDate }) { data={groupOptions} value={selectedGroupId} onChange={handleGroupChange} // Use the new handler - style={{ width: '220px' }} + w={'220px'} clearable={true} // Allow clearing the selection /> @@ -1442,7 +1081,7 @@ export default function TVChannelGuide({ startDate, endDate }) { data={profileOptions} value={selectedProfileId} onChange={handleProfileChange} // Use the new handler - style={{ width: '180px' }} + w={'180px'} clearable={true} // Allow clearing the selection /> @@ -1460,14 +1099,14 @@ export default function TVChannelGuide({ startDate, endDate }) { onClick={openRules} style={{ backgroundColor: '#245043', - border: '1px solid #3BA882', - color: '#FFFFFF', }} + bd={'1px solid #3BA882'} + color='#FFFFFF' > Series Rules - + {filteredChannels.length}{' '} {filteredChannels.length === 1 ? 'channel' : 'channels'} @@ -1477,34 +1116,34 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Guide container with headers and scrollable content */} {/* Logo header - Sticky, non-scrollable */} {/* Logo header cell - sticky in both directions */} {/* Timeline header with its own scrollbar */} @@ -1512,122 +1151,33 @@ export default function TVChannelGuide({ startDate, endDate }) { style={{ flex: 1, overflow: 'hidden', - position: 'relative', }} + pos='relative' > - {' '} - {hourTimeline.map((hourData) => { - const { time, isNewDay } = hourData; - - return ( - handleTimeClick(time, e)} - > - {/* Remove the special day label for new days since we'll show day for all hours */} - - {/* Position time label at the left border of each hour block */} - - {/* Show day above time for every hour using the same format */} - - {formatDayLabel(time)}{' '} - {/* Use same formatDayLabel function for all hours */} - - {time.format(timeFormat)} - - {/*time.format('A')*/} - - - - {/* Hour boundary marker - more visible */} - - - {/* Quarter hour tick marks */} - - {[15, 30, 45].map((minute) => ( - - ))} - - - ); - })} + @@ -1638,22 +1188,23 @@ export default function TVChannelGuide({ startDate, endDate }) { ref={guideContainerRef} style={{ flex: 1, - position: 'relative', overflow: 'hidden', }} + pos='relative' > + {nowPosition >= 0 && ( )} @@ -1674,13 +1225,7 @@ export default function TVChannelGuide({ startDate, endDate }) { {GuideRow} ) : ( - + No channels match your filters - - - {recordingForProgram && ( - <> - - - - )} - {existingRuleMode && ( - - )} - - + + }> + setRecordChoiceOpen(false)} + program={recordChoiceProgram} + recording={recordingForProgram} + existingRuleMode={existingRuleMode} + onRecordOne={() => recordOne(recordChoiceProgram)} + onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')} + onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')} + onExistingRuleModeChange={setExistingRuleMode} + /> + + )} {/* Series rules modal */} {rulesOpen && ( - setRulesOpen(false)} - title="Series Recording Rules" - centered - radius="md" - zIndex={9999} - overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }} - styles={{ - content: { backgroundColor: '#18181B', color: 'white' }, - header: { backgroundColor: '#18181B', color: 'white' }, - title: { color: 'white' }, - }} - > - - {(!rules || rules.length === 0) && ( - - No series rules configured - - )} - {rules && - rules.map((r) => ( - - - {r.title || r.tvg_id} —{' '} - {r.mode === 'new' ? 'New episodes' : 'Every episode'} - - - - - - - ))} - - + + }> + setRulesOpen(false)} + rules={rules} + onRulesUpdate={setRules} + /> + + )} ); diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx index 889e32c9..f95212d6 100644 --- a/frontend/src/pages/Logos.jsx +++ b/frontend/src/pages/Logos.jsx @@ -1,34 +1,34 @@ import React, { useEffect, useCallback, useState } from 'react'; -import { Box, Tabs, Flex, Text } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; +import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core'; import useLogosStore from '../store/logos'; import useVODLogosStore from '../store/vodLogos'; import LogosTable from '../components/tables/LogosTable'; import VODLogosTable from '../components/tables/VODLogosTable'; +import { showNotification } from '../utils/notificationUtils.js'; const LogosPage = () => { - const { fetchAllLogos, needsAllLogos, logos } = useLogosStore(); - const { totalCount } = useVODLogosStore(); + const logos = useLogosStore(s => s.logos); + const totalCount = useVODLogosStore(s => s.totalCount); const [activeTab, setActiveTab] = useState('channel'); - - const channelLogosCount = Object.keys(logos).length; - const vodLogosCount = totalCount; + const logoCount = activeTab === 'channel' + ? Object.keys(logos).length + : totalCount; const loadChannelLogos = useCallback(async () => { try { // Only fetch all logos if we haven't loaded them yet - if (needsAllLogos()) { - await fetchAllLogos(); + if (useLogosStore.getState().needsAllLogos()) { + await useLogosStore.getState().fetchAllLogos(); } } catch (err) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to load channel logos', color: 'red', }); console.error('Failed to load channel logos:', err); } - }, [fetchAllLogos, needsAllLogos]); + }, []); useEffect(() => { // Always load channel logos on mount @@ -39,51 +39,41 @@ const LogosPage = () => { {/* Header with title and tabs */} Logos - ({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '} - logo - {(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !== - 1 - ? 's' - : ''} - ) + ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'}) - - Channel Logos - VOD Logos - + + Channel Logos + VOD Logos + diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx index f2902523..21df7faf 100644 --- a/frontend/src/pages/Plugins.jsx +++ b/frontend/src/pages/Plugins.jsx @@ -1,353 +1,108 @@ -import React, { useEffect, useState } from 'react'; +import React, { + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { - AppShell, - Box, + ActionIcon, Alert, + AppShellMain, + Box, Button, - Card, + Divider, + FileInput, Group, Loader, + Modal, + SimpleGrid, Stack, Switch, Text, - TextInput, - NumberInput, - Select, - Divider, - ActionIcon, - SimpleGrid, - Modal, - FileInput, } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { RefreshCcw, Trash2 } from 'lucide-react'; -import API from '../api'; -import { notifications } from '@mantine/notifications'; +import { showNotification, updateNotification, } from '../utils/notificationUtils.js'; +import { usePluginStore } from '../store/plugins.jsx'; +import { + deletePluginByKey, + importPlugin, + runPluginAction, + setPluginEnabled, + updatePluginSettings, +} from '../utils/pages/PluginsUtils.js'; +import { RefreshCcw } from 'lucide-react'; +import ErrorBoundary from '../components/ErrorBoundary.jsx'; +const PluginCard = React.lazy(() => + import('../components/cards/PluginCard.jsx')); -const Field = ({ field, value, onChange }) => { - const common = { label: field.label, description: field.help_text }; - const effective = value ?? field.default; - switch (field.type) { - case 'boolean': - return ( - onChange(field.id, e.currentTarget.checked)} - label={field.label} - description={field.help_text} - /> - ); - case 'number': - return ( - onChange(field.id, v)} - {...common} - /> - ); - case 'select': - return ( - onUISettingsChange('table-size', val)} - data={[ - { - value: 'default', - label: 'Default', - }, - { - value: 'compact', - label: 'Compact', - }, - { - value: 'large', - label: 'Large', - }, - ]} - /> - onUISettingsChange('date-format', val)} - data={[ - { - value: 'mdy', - label: 'MM/DD/YYYY', - }, - { - value: 'dmy', - label: 'DD/MM/YYYY', - }, - ]} - /> - ({ - value: `${option.id}`, - label: option.name, - }))} - /> - ({ - label: r.label, - value: `${r.value}`, - }))} - /> - - - - Auto-Import Mapped Files - - - - - - - {rehashSuccess && ( - - )} - - - - - - - - - - - System Settings - - - {generalSettingsSaved && ( - - )} - - Configure how many system events (channel start/stop, - buffering, etc.) to keep in the database. Events are - displayed on the Stats page. - - { - form.setFieldValue('max-system-events', value); - }} - min={10} - max={1000} - step={10} - /> - - - - - - - - - User-Agents - - - - - - - Stream Profiles - - - - - - - + + Network Access - {accordianValue == 'network-access' && ( + {accordianValue === 'network-access' && ( Comma-Delimited CIDR ranges )} - - -
- - {networkAccessSaved && ( - - )} - {networkAccessError && ( - - )} - {Object.entries(NETWORK_ACCESS_OPTIONS).map( - ([key, config]) => { - return ( - - ); - } - )} + + + + }> + + + + + - - - - -
-
-
- - - + + Proxy Settings - - -
- - {proxySettingsSaved && ( - - )} - {Object.entries(PROXY_SETTINGS_OPTIONS).map( - ([key, config]) => { - // Determine if this field should be a NumberInput - const isNumericField = [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - ].includes(key); + + + + }> + + + + + - const isFloatField = key === 'buffering_speed'; - - if (isNumericField) { - return ( - - ); - } else if (isFloatField) { - return ( - - ); - } else { - return ( - - ); - } - } - )} - - - - - - -
-
-
- - - Backup & Restore - - - - + + Backup & Restore + + + }> + + + + + )}
- - setTmdbHelpOpen(false)} - title="How to get a TMDB API key" - size="lg" - overlayProps={{ backgroundOpacity: 0.55, blur: 2 }} - > - - - Dispatcharr uses TMDB (The Movie Database) for artwork and - metadata. You can create a key in a few minutes: - - - - Visit{' '} - - themoviedb.org - {' '} - and sign in or create a free account. - - - Open your{' '} - - TMDB account settings - {' '} - and choose API. - - - Complete the short API application and copy the v3 API key into - the field above. - - - - TMDB issues separate v3 and v4 keys. Dispatcharr only needs the v3 - API key for metadata lookups. - - - - - { - setRehashConfirmOpen(false); - setRehashDialogType(null); - // Clear pending values when dialog is cancelled - setPendingChangedSettings(null); - }} - onConfirm={handleRehashConfirm} - title={ - rehashDialogType === 'save' - ? 'Save Settings and Rehash Streams' - : 'Confirm Stream Rehash' - } - message={ -
- {`Are you sure you want to rehash all streams? - -This process may take a while depending on the number of streams. -Do not shut down Dispatcharr until the rehashing is complete. -M3U refreshes will be blocked until this process finishes. - -Please ensure you have time to let this complete before proceeding.`} -
- } - confirmLabel={ - rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash' - } - cancelLabel="Cancel" - actionKey="rehash-streams" - onSuppressChange={suppressWarning} - size="md" - /> - - setNetworkAccessConfirmOpen(false)} - onConfirm={saveNetworkAccess} - title={`Confirm Network Access Blocks`} - message={ - <> - - Your client {clientIpAddress && `(${clientIpAddress}) `}is not included in the allowed networks for the web - UI. Are you sure you want to proceed? - - -
    - {netNetworkAccessConfirmCIDRs.map((cidr) => ( -
  • {cidr}
  • - ))} -
- - } - confirmLabel="Save" - cancelLabel="Cancel" - size="md" - /> ); }; diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js index 1f4ff671..68bb74b2 100644 --- a/frontend/src/pages/guideUtils.js +++ b/frontend/src/pages/guideUtils.js @@ -1,7 +1,26 @@ -import dayjs from 'dayjs'; +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; export const EXPANDED_PROGRAM_HEIGHT = 180; +/** 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); export function buildChannelIdMap(channels, tvgsById, epgs = {}) { const map = new Map(); @@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) { return map; } -export function mapProgramsByChannel(programs, channelIdByTvgId) { +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 ?? dayjs(program.start_time).valueOf(); - const endMs = program.endMs ?? dayjs(program.end_time).valueOf(); + const startMs = program.startMs ?? convertToMs(program.start_time); + const endMs = program.endMs ?? convertToMs(program.end_time); const programData = { ...program, startMs, endMs, + programStart: initializeTime(program.startMs), + programEnd: initializeTime(program.endMs), + // Precompute live/past status + isLive: nowMs >= program.startMs && nowMs < program.endMs, + isPast: nowMs >= program.endMs, }; // Add this program to all channels that share the same TVG ID @@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) { }); return map; -} +}; export function computeRowHeights( filteredChannels, @@ -94,3 +120,282 @@ export function computeRowHeights( return expanded ? expandedHeight : 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; +} + +export const mapRecordingsByProgramId = (recordings) => { + const map = new Map(); + (recordings || []).forEach((recording) => { + const programId = recording?.custom_properties?.program?.id; + if (programId != null) { + 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 deleteSeriesRuleByTvgId = async (tvg_id) => { + await API.deleteSeriesRule(tvg_id); +} + +export const evaluateSeriesRulesByTvgId = async (tvg_id) => { + await API.evaluateSeriesRules(tvg_id); +} \ No newline at end of file diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx new file mode 100644 index 00000000..e8d0b065 --- /dev/null +++ b/frontend/src/store/plugins.jsx @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import API from '../api'; + +export const usePluginStore = create((set, get) => ({ + plugins: [], + loading: false, + error: null, + + fetchPlugins: async () => { + set({ loading: true, error: null }); + try { + const response = await API.getPlugins(); + set({ plugins: response || [], loading: false }); + } catch (error) { + set({ error, loading: false }); + } + }, + + updatePlugin: (key, updates) => { + set((state) => ({ + plugins: state.plugins.map((p) => + p.key === key ? { ...p, ...updates } : p + ), + })); + }, + + addPlugin: (plugin) => { + set((state) => ({ plugins: [...state.plugins, plugin] })); + }, + + removePlugin: (key) => { + set((state) => ({ + plugins: state.plugins.filter((p) => p.key !== key), + })); + }, + + invalidatePlugins: () => { + set({ plugins: [] }); + get().fetchPlugins(); + }, +})); \ No newline at end of file diff --git a/frontend/src/utils/cards/PluginCardUtils.js b/frontend/src/utils/cards/PluginCardUtils.js new file mode 100644 index 00000000..8752e019 --- /dev/null +++ b/frontend/src/utils/cards/PluginCardUtils.js @@ -0,0 +1,24 @@ +export const getConfirmationDetails = (action, plugin, settings) => { + const actionConfirm = action.confirm; + const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm'); + let requireConfirm = false; + let confirmTitle = `Run ${action.label}?`; + let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`; + + if (actionConfirm) { + if (typeof actionConfirm === 'boolean') { + requireConfirm = actionConfirm; + } else if (typeof actionConfirm === 'object') { + requireConfirm = actionConfirm.required !== false; + if (actionConfirm.title) confirmTitle = actionConfirm.title; + if (actionConfirm.message) confirmMessage = actionConfirm.message; + } + } else if (confirmField) { + const settingVal = settings?.confirm; + const effectiveConfirm = + (settingVal !== undefined ? settingVal : confirmField.default) ?? false; + requireConfirm = !!effectiveConfirm; + } + + return { requireConfirm, confirmTitle, confirmMessage }; +}; diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index b7490f88..64a50947 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -12,6 +12,41 @@ dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(timezone); +export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); + +export const initializeTime = (dateTime) => dayjs(dateTime); + +export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); + +export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2); + +export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2); + +export const isSame = (date1, date2, unit = 'day') => + dayjs(date1).isSame(date2, unit); + +export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit); + +export const diff = (date1, date2, unit = 'millisecond') => + dayjs(date1).diff(date2, unit); + +export const format = (dateTime, formatStr) => + dayjs(dateTime).format(formatStr); + +export const getNow = () => dayjs(); + +export const getNowMs = () => Date.now(); + +export const roundToNearest = (dateTime, minutes) => { + const current = initializeTime(dateTime); + const minute = current.minute(); + const snappedMinute = Math.round(minute / minutes) * minutes; + + return snappedMinute === 60 + ? current.add(1, 'hour').minute(0) + : current.minute(snappedMinute); +}; + export const useUserTimeZone = () => { const settings = useSettingsStore((s) => s.settings); const [timeZone, setTimeZone] = useLocalStorage( @@ -38,15 +73,15 @@ export const useTimeHelpers = () => { (value) => { if (!value) return dayjs.invalid(); try { - return dayjs(value).tz(timeZone); + return initializeTime(value).tz(timeZone); } catch (error) { - return dayjs(value); + return initializeTime(value); } }, [timeZone] ); - const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]); + const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]); return { timeZone, toUserTime, userNow }; }; @@ -68,7 +103,7 @@ export const useDateTimeFormat = () => { const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm'; const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; - return [timeFormat, dateFormat] + return [timeFormat, dateFormat]; }; export const toTimeString = (value) => { @@ -78,7 +113,7 @@ export const toTimeString = (value) => { if (parsed.isValid()) return parsed.format('HH:mm'); return value; } - const parsed = dayjs(value); + const parsed = initializeTime(value); return parsed.isValid() ? parsed.format('HH:mm') : '00:00'; }; @@ -86,4 +121,138 @@ export const parseDate = (value) => { if (!value) return null; const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true); return parsed.isValid() ? parsed.toDate() : null; +}; + +const TIMEZONE_FALLBACKS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Phoenix', + 'America/Anchorage', + 'Pacific/Honolulu', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Warsaw', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', +]; + +const getSupportedTimeZones = () => { + try { + if (typeof Intl.supportedValuesOf === 'function') { + return Intl.supportedValuesOf('timeZone'); + } + } catch (error) { + console.warn('Unable to enumerate supported time zones:', error); + } + return TIMEZONE_FALLBACKS; +}; + +const getTimeZoneOffsetMinutes = (date, timeZone) => { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + const parts = dtf.formatToParts(date).reduce((acc, part) => { + if (part.type !== 'literal') acc[part.type] = part.value; + return acc; + }, {}); + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + return (asUTC - date.getTime()) / 60000; + } catch (error) { + console.warn(`Failed to compute offset for ${timeZone}:`, error); + return 0; + } +}; + +const formatOffset = (minutes) => { + const rounded = Math.round(minutes); + const sign = rounded < 0 ? '-' : '+'; + const absolute = Math.abs(rounded); + const hours = String(Math.floor(absolute / 60)).padStart(2, '0'); + const mins = String(absolute % 60).padStart(2, '0'); + return `UTC${sign}${hours}:${mins}`; +}; + +export const buildTimeZoneOptions = (preferredZone) => { + const zones = getSupportedTimeZones(); + const referenceYear = new Date().getUTCFullYear(); + const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0)); + const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0)); + + const options = zones + .map((zone) => { + const janOffset = getTimeZoneOffsetMinutes(janDate, zone); + const julOffset = getTimeZoneOffsetMinutes(julDate, zone); + const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone); + const minOffset = Math.min(janOffset, julOffset); + const maxOffset = Math.max(janOffset, julOffset); + const usesDst = minOffset !== maxOffset; + const labelParts = [`now ${formatOffset(currentOffset)}`]; + if (usesDst) { + labelParts.push( + `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}` + ); + } + return { + value: zone, + label: `${zone} (${labelParts.join(' | ')})`, + numericOffset: minOffset, + }; + }) + .sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + if ( + preferredZone && + !options.some((option) => option.value === preferredZone) + ) { + const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone); + options.push({ + value: preferredZone, + label: `${preferredZone} (now ${formatOffset(currentOffset)})`, + numericOffset: currentOffset, + }); + options.sort((a, b) => { + if (a.numericOffset !== b.numericOffset) { + return a.numericOffset - b.numericOffset; + } + return a.value.localeCompare(b.value); + }); + } + return options; +}; + +export const getDefaultTimeZone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch (error) { + return 'UTC'; + } }; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js new file mode 100644 index 00000000..7fa272d0 --- /dev/null +++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js @@ -0,0 +1,22 @@ +import API from '../../../api.js'; + +export const getComskipConfig = async () => { + return await API.getComskipConfig(); +}; + +export const uploadComskipIni = async (file) => { + return await API.uploadComskipIni(file); +}; + +export const getDvrSettingsFormInitialValues = () => { + return { + 'dvr-tv-template': '', + 'dvr-movie-template': '', + 'dvr-tv-fallback-template': '', + 'dvr-movie-fallback-template': '', + 'dvr-comskip-enabled': false, + 'dvr-comskip-custom-path': '', + 'dvr-pre-offset-minutes': 0, + 'dvr-post-offset-minutes': 0, + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js new file mode 100644 index 00000000..fe1eea8a --- /dev/null +++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js @@ -0,0 +1,29 @@ +import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js'; + +export const getNetworkAccessFormInitialValues = () => { + return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = '0.0.0.0/0,::/0'; + return acc; + }, {}); +}; + +export const getNetworkAccessFormValidation = () => { + return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = (value) => { + if ( + value + .split(',') + .some( + (cidr) => + !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX)) + ) + ) { + return 'Invalid CIDR range'; + } + + return null; + }; + return acc; + }, {}); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js new file mode 100644 index 00000000..864dd9b1 --- /dev/null +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -0,0 +1,18 @@ +import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; + +export const getProxySettingsFormInitialValues = () => { + return Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => { + acc[key] = ''; + return acc; + }, {}); +}; + +export const getProxySettingDefaults = () => { + return { + buffering_timeout: 15, + buffering_speed: 1.0, + redis_chunk_ttl: 60, + channel_shutdown_delay: 0, + channel_init_grace_period: 5, + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js new file mode 100644 index 00000000..2ff5dd55 --- /dev/null +++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js @@ -0,0 +1,19 @@ +import { isNotEmpty } from '@mantine/form'; + +export const getStreamSettingsFormInitialValues = () => { + return { + 'default-user-agent': '', + 'default-stream-profile': '', + 'preferred-region': '', + 'auto-import-mapped-files': true, + 'm3u-hash-key': [], + }; +}; + +export const getStreamSettingsFormValidation = () => { + return { + 'default-user-agent': isNotEmpty('Select a user agent'), + 'default-stream-profile': isNotEmpty('Select a stream profile'), + 'preferred-region': isNotEmpty('Select a region'), + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js new file mode 100644 index 00000000..75c4f513 --- /dev/null +++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js @@ -0,0 +1,5 @@ +export const getSystemSettingsFormInitialValues = () => { + return { + 'max-system-events': 100, + }; +}; diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js new file mode 100644 index 00000000..79e99d96 --- /dev/null +++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js @@ -0,0 +1,14 @@ +import { createSetting, updateSetting } from '../../pages/SettingsUtils.js'; + +export const saveTimeZoneSetting = async (tzValue, settings) => { + const existing = settings['system-time-zone']; + if (existing?.id) { + await updateSetting({ ...existing, value: tzValue }); + } else { + await createSetting({ + key: 'system-time-zone', + name: 'System Time Zone', + value: tzValue, + }); + } +}; \ No newline at end of file diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js new file mode 100644 index 00000000..8562face --- /dev/null +++ b/frontend/src/utils/networkUtils.js @@ -0,0 +1,4 @@ +export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/; + +export const IPV6_CIDR_REGEX = + /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/; diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js new file mode 100644 index 00000000..ba965343 --- /dev/null +++ b/frontend/src/utils/notificationUtils.js @@ -0,0 +1,9 @@ +import { notifications } from '@mantine/notifications'; + +export function showNotification(notificationObject) { + return notifications.show(notificationObject); +} + +export function updateNotification(notificationId, notificationObject) { + return notifications.update(notificationId, notificationObject); +} \ No newline at end of file diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js new file mode 100644 index 00000000..bae98e93 --- /dev/null +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -0,0 +1,17 @@ +import API from '../../api.js'; + +export const updatePluginSettings = async (key, settings) => { + return await API.updatePluginSettings(key, settings); +}; +export const runPluginAction = async (key, actionId) => { + return await API.runPluginAction(key, actionId); +}; +export const setPluginEnabled = async (key, next) => { + return await API.setPluginEnabled(key, next); +}; +export const importPlugin = async (importFile) => { + return await API.importPlugin(importFile); +}; +export const deletePluginByKey = (key) => { + return API.deletePlugin(key); +}; \ No newline at end of file diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js new file mode 100644 index 00000000..e6179f06 --- /dev/null +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -0,0 +1,104 @@ +import API from '../../api.js'; + +export const checkSetting = async (values) => { + return await API.checkSetting(values); +}; + +export const updateSetting = async (values) => { + return await API.updateSetting(values); +}; + +export const createSetting = async (values) => { + return await API.createSetting(values); +}; + +export const rehashStreams = async () => { + return await API.rehashStreams(); +}; + +export const saveChangedSettings = async (settings, changedSettings) => { + for (const updatedKey in changedSettings) { + const existing = settings[updatedKey]; + if (existing?.id) { + const result = await updateSetting({ + ...existing, + value: changedSettings[updatedKey], + }); + // API functions return undefined on error + if (!result) { + throw new Error('Failed to update setting'); + } + } else { + const result = await createSetting({ + key: updatedKey, + name: updatedKey.replace(/-/g, ' '), + value: changedSettings[updatedKey], + }); + // API functions return undefined on error + if (!result) { + throw new Error('Failed to create setting'); + } + } + } +}; + +export const getChangedSettings = (values, settings) => { + const changedSettings = {}; + + for (const settingKey in values) { + // Only compare against existing value if the setting exists + const existing = settings[settingKey]; + + // Convert array values (like m3u-hash-key) to comma-separated strings + const stringValue = Array.isArray(values[settingKey]) + ? values[settingKey].join(',') + : `${values[settingKey]}`; + + // Skip empty values to avoid validation errors + if (!stringValue) { + continue; + } + + if (!existing) { + // Create new setting on save + changedSettings[settingKey] = stringValue; + } else if (stringValue !== String(existing.value)) { + // If the user changed the setting's value from what's in the DB: + changedSettings[settingKey] = stringValue; + } + } + return changedSettings; +}; + +export const parseSettings = (settings) => { + return Object.entries(settings).reduce((acc, [key, value]) => { + // Modify each value based on its own properties + switch (value.value) { + case 'true': + value.value = true; + break; + case 'false': + value.value = false; + break; + } + + let val = null; + switch (key) { + case 'm3u-hash-key': + // Split comma-separated string, filter out empty strings + val = value.value ? value.value.split(',').filter((v) => v) : []; + break; + case 'dvr-pre-offset-minutes': + case 'dvr-post-offset-minutes': + val = Number.parseInt(value.value || '0', 10); + if (Number.isNaN(val)) val = 0; + break; + default: + val = value.value; + break; + } + + acc[key] = val; + return acc; + }, {}); +}; \ No newline at end of file