Merge branch 'dev' into Media-Server

This commit is contained in:
Dispatcharr 2026-01-03 19:16:35 -06:00
commit 8be5b4a539
40 changed files with 3604 additions and 3322 deletions

View file

@ -30,23 +30,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) - 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 - 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 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) - 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) - 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) - 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) - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils)
- Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal) with loading fallbacks - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks
- Removed unused Dashboard and Home pages - 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 - 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 - 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 ### 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 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) - 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 - 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) - 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) - 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) - 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)

View file

@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type' # Handle the format 'category_name|category_type'
if '|' in value: if '|' in value:
category_name, category_type = value.split('|', 1) category_name, category_type = value.rsplit('|', 1)
return queryset.filter( return queryset.filter(
m3u_relations__category__name=category_name, m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type m3u_relations__category__category_type=category_type
@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type' # Handle the format 'category_name|category_type'
if '|' in value: if '|' in value:
category_name, category_type = value.split('|', 1) category_name, category_type = value.rsplit('|', 1)
return queryset.filter( return queryset.filter(
m3u_relations__category__name=category_name, m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type m3u_relations__category__category_type=category_type
@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if category: if category:
if '|' in category: if '|' in category:
cat_name, cat_type = category.split('|', 1) cat_name, cat_type = category.rsplit('|', 1)
if cat_type == 'movie': 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[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 where_conditions[1] = "1=0" # Exclude series

View file

@ -1292,8 +1292,17 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
try: try:
episode_id = str(episode_data.get('id')) episode_id = str(episode_data.get('id'))
episode_name = episode_data.get('title', 'Unknown Episode') episode_name = episode_data.get('title', 'Unknown Episode')
season_number = episode_data['_season_number'] # Ensure season and episode numbers are integers (API may return strings)
episode_number = episode_data.get('episode_num', 0) 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', {}) info = episode_data.get('info', {})
# Extract episode metadata # 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) # Check if we already have this episode pending creation (multiple streams for same episode)
if not episode and episode_key in episodes_pending_creation: if not episode and episode_key in episodes_pending_creation:
episode = episodes_pending_creation[episode_key] 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: if episode:
# Update existing 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: if key in episode_pk_map:
relation.episode = episode_pk_map[key] 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 # Update existing episodes
if episodes_to_update: if episodes_to_update:
Episode.objects.bulk_update(episodes_to_update, [ Episode.objects.bulk_update(episodes_to_update, [

View file

@ -35,9 +35,6 @@ RUN rm -rf /app/frontend
# Copy built frontend assets # Copy built frontend assets
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
# Run Django collectstatic
RUN python manage.py collectstatic --noinput
# Add timestamp argument # Add timestamp argument
ARG TIMESTAMP ARG TIMESTAMP

View file

@ -100,7 +100,7 @@ export POSTGRES_DIR=/data/db
if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
# Define all variables to process # Define all variables to process
variables=( 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 POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
@ -174,9 +174,9 @@ else
pids+=("$nginx_pid") pids+=("$nginx_pid")
fi fi
cd /app # Run Django commands as non-root user to prevent permission issues
python manage.py migrate --noinput su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput"
python manage.py collectstatic --noinput su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput"
# Select proper uwsgi config based on environment # Select proper uwsgi config based on environment
if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then

View file

@ -15,6 +15,7 @@ DATA_DIRS=(
APP_DIRS=( APP_DIRS=(
"/app/logo_cache" "/app/logo_cache"
"/app/media" "/app/media"
"/app/static"
) )
# Create all directories # Create all directories

View file

@ -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 (
<Switch
checked={!!effective}
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
label={field.label}
description={field.help_text}
/>
);
case 'number':
return (
<NumberInput
value={value ?? field.default ?? 0}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'select':
return (
<Select
value={(value ?? field.default ?? '') + ''}
data={(field.options || []).map((o) => ({
value: o.value + '',
label: o.label,
}))}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'string':
default:
return (
<TextInput
value={value ?? field.default ?? ''}
onChange={(e) => onChange(field.id, e.currentTarget.value)}
{...common}
/>
);
}
};

View file

@ -0,0 +1,206 @@
import React from "react";
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../pages/guideUtils.js';
import {Box, Flex, Text} from "@mantine/core";
import {Play} from "lucide-react";
import logo from "../images/logo.png";
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);
const PlaceholderProgram = () => {
return <>
{Array.from({length: Math.ceil(24 / 2)}).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
left={placeholderIndex * (HOUR_WIDTH * 2)}
top={0}
w={HOUR_WIDTH * 2}
h={rowHeight - 4}
bd={'1px dashed #2D3748'}
bdrs={4}
display={'flex'}
c='#4A5568'
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>;
}
return (
<div
data-testid="guide-row"
style={{ ...style, width: contentWidth, height: rowHeight }}
>
<Box
style={{
borderBottom: '0px solid #27272A',
transition: 'height 0.2s ease',
overflow: 'visible',
}}
display={'flex'}
h={'100%'}
pos='relative'
>
<Box
className="channel-logo"
style={{
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#18181B',
borderRight: '1px solid #27272A',
borderBottom: '1px solid #27272A',
boxShadow: '2px 0 5px rgba(0,0,0,0.2)',
zIndex: 30,
transition: 'height 0.2s ease',
cursor: 'pointer',
}}
w={CHANNEL_WIDTH}
miw={CHANNEL_WIDTH}
display={'flex'}
left={0}
h={'100%'}
pos='relative'
onClick={(event) => handleLogoClick(channel, event)}
onMouseEnter={() => setHoveredChannelId(channel.id)}
onMouseLeave={() => setHoveredChannelId(null)}
>
{hoveredChannelId === channel.id && (
<Flex
align="center"
justify="center"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.7)',
zIndex: 10,
animation: 'fadeIn 0.2s',
}}
pos='absolute'
top={0}
left={0}
right={0}
bottom={0}
w={'100%'}
h={'100%'}
>
<Play size={32} color="#fff" fill="#fff" />
</Flex>
)}
<Flex
direction="column"
align="center"
justify="space-between"
style={{
boxSizing: 'border-box',
zIndex: 5,
}}
w={'100%'}
h={'100%'}
p={'4px'}
pos='relative'
>
<Box
style={{
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
w={'100%'}
h={`${rowHeight - 32}px`}
display={'flex'}
p={'4px'}
mb={'4px'}
>
<img
src={logos[channel.logo_id]?.cache_url || logo}
alt={channel.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</Box>
<Text
size="sm"
weight={600}
style={{
transform: 'translateX(-50%)',
backgroundColor: '#18181B',
alignItems: 'center',
justifyContent: 'center',
}}
pos='absolute'
bottom={4}
left={'50%'}
p={'2px 8px'}
bdrs={4}
fz={'0.85em'}
bd={'1px solid #27272A'}
h={'24px'}
display={'flex'}
miw={'36px'}
>
{channel.channel_number || '-'}
</Text>
</Flex>
</Box>
<Box
style={{
transition: 'height 0.2s ease',
}}
flex={1}
pos='relative'
h={'100%'}
pl={0}
>
{channelPrograms.length > 0 ? (
channelPrograms.map((program) =>
renderProgram(program, undefined, channel)
)
) : <PlaceholderProgram />}
</Box>
</Box>
</div>
);
});
export default GuideRow;

View file

@ -0,0 +1,105 @@
import React from 'react';
import { Box, Text } from '@mantine/core';
import { format } from '../utils/dateTimeUtils.js';
import { HOUR_WIDTH } from '../pages/guideUtils.js';
const HourBlock = React.memo(({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
const { time, isNewDay } = hourData;
return (
<Box
key={format(time)}
style={{
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
}}
w={HOUR_WIDTH}
h={'40px'}
pos='relative'
c='#a0aec0'
onClick={(e) => handleTimeClick(time, e)}
>
<Text
size="sm"
style={{ transform: 'none' }}
pos='absolute'
top={8}
left={4}
bdrs={2}
lh={1.2}
ta='left'
>
<Text
span
size="xs"
display={'block'}
opacity={0.7}
fw={isNewDay ? 600 : 400}
c={isNewDay ? '#3BA882' : undefined}
>
{formatDayLabel(time)}
</Text>
{format(time, timeFormat)}
<Text span size="xs" ml={1} opacity={0.7} />
</Text>
<Box
style={{
backgroundColor: '#27272A',
zIndex: 10,
}}
pos='absolute'
left={0}
top={0}
bottom={0}
w={'1px'}
/>
<Box
style={{ justifyContent: 'space-between' }}
pos='absolute'
bottom={0}
w={'100%'}
display={'flex'}
p={'0 1px'}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{ backgroundColor: '#718096' }}
w={'1px'}
h={'8px'}
pos='absolute'
bottom={0}
left={`${(minute / 60) * 100}%`}
/>
))}
</Box>
</Box>
);
});
const HourTimeline = React.memo(({
hourTimeline,
timeFormat,
formatDayLabel,
handleTimeClick
}) => {
return (
<>
{hourTimeline.map((hourData) => (
<HourBlock
key={format(hourData.time)}
hourData={hourData}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
))}
</>
);
});
export default HourTimeline;

View file

@ -0,0 +1,258 @@
import React, { useState } from 'react';
import { showNotification } from '../../utils/notificationUtils.js';
import { Field } from '../Field.jsx';
import {
ActionIcon,
Button,
Card,
Divider,
Group,
Stack,
Switch,
Text,
} from '@mantine/core';
import { Trash2 } from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
const PluginFieldList = ({ plugin, settings, updateField }) => {
return plugin.fields.map((f) => (
<Field
key={f.id}
field={f}
value={settings?.[f.id]}
onChange={updateField}
/>
));
};
const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
return plugin.actions.map((action) => (
<Group key={action.id} justify="space-between">
<div>
<Text>{action.label}</Text>
{action.description && (
<Text size="sm" c="dimmed">
{action.description}
</Text>
)}
</div>
<Button
loading={running}
disabled={!enabled}
onClick={() => handlePluginRun(action)}
size="xs"
>
{running ? 'Running…' : 'Run'}
</Button>
</Group>
));
};
const PluginActionStatus = ({ running, lastResult }) => {
return (
<>
{running && (
<Text size="sm" c="dimmed">
Running action please wait
</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">
Output: {lastResult.file}
</Text>
)}
{!running && lastResult?.error && (
<Text size="sm" c="red">
Error: {String(lastResult.error)}
</Text>
)}
</>
);
};
const PluginCard = ({
plugin,
onSaveSettings,
onRunAction,
onToggleEnabled,
onRequireTrust,
onRequestDelete,
onRequestConfirm,
}) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
// Keep local enabled state in sync with props (e.g., after import + enable)
React.useEffect(() => {
setEnabled(!!plugin.enabled);
}, [plugin.enabled]);
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key]);
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
};
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
showNotification({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
});
} finally {
setSaving(false);
}
};
const missing = plugin.missing;
const handleEnableChange = () => {
return async (e) => {
const next = e.currentTarget.checked;
if (next && !plugin.ever_enabled && onRequireTrust) {
const ok = await onRequireTrust(plugin);
if (!ok) {
// Revert
setEnabled(false);
return;
}
}
setEnabled(next);
const resp = await onToggleEnabled(plugin.key, next);
if (next && resp?.ever_enabled) {
plugin.ever_enabled = true;
}
};
};
const handlePluginRun = async (a) => {
setRunning(true);
setLastResult(null);
try {
// Determine if confirmation is required from action metadata or fallback field
const { requireConfirm, confirmTitle, confirmMessage } =
getConfirmationDetails(a, plugin, settings);
if (requireConfirm) {
const confirmed = await onRequestConfirm(confirmTitle, confirmMessage);
if (!confirmed) {
// User canceled, abort the action
return;
}
}
// Save settings before running to ensure backend uses latest values
try {
await onSaveSettings(plugin.key, settings);
} catch (e) {
/* ignore, run anyway */
}
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
const msg = resp.result?.message || 'Plugin action completed';
showNotification({
title: plugin.name,
message: msg,
color: 'green',
});
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
showNotification({
title: `${plugin.name} error`,
message: String(err),
color: 'red',
});
}
} finally {
setRunning(false);
}
};
return (
<Card
shadow="sm"
radius="md"
withBorder
opacity={!missing && enabled ? 1 : 0.6}
>
<Group justify="space-between" mb="xs" align="center">
<div>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">
{plugin.description}
</Text>
</div>
<Group gap="xs" align="center">
<ActionIcon
variant="subtle"
color="red"
title="Delete plugin"
onClick={() => onRequestDelete && onRequestDelete(plugin)}
>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">
v{plugin.version || '1.0.0'}
</Text>
<Switch
checked={!missing && enabled}
onChange={handleEnableChange()}
size="xs"
onLabel="On"
offLabel="Off"
disabled={missing}
/>
</Group>
</Group>
{missing && (
<Text size="sm" c="red">
Missing plugin files. Re-import or delete this entry.
</Text>
)}
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
{!missing && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
plugin={plugin}
enabled={enabled}
running={running}
handlePluginRun={handlePluginRun}
/>
<PluginActionStatus running={running} lastResult={lastResult} />
</Stack>
</>
)}
</Card>
);
};
export default PluginCard;

View file

@ -369,7 +369,8 @@ const LiveGroupFilter = ({
if ( if (
group.custom_properties?.custom_epg_id !== group.custom_properties?.custom_epg_id !==
undefined || undefined ||
group.custom_properties?.force_dummy_epg group.custom_properties?.force_dummy_epg ||
group.custom_properties?.force_epg_selected
) { ) {
selectedValues.push('force_epg'); selectedValues.push('force_epg');
} }
@ -432,23 +433,20 @@ const LiveGroupFilter = ({
// Handle force_epg // Handle force_epg
if (selectedOptions.includes('force_epg')) { if (selectedOptions.includes('force_epg')) {
// Migrate from old force_dummy_epg if present // Set default to force_dummy_epg if no EPG settings exist yet
if ( if (
newCustomProps.force_dummy_epg && newCustomProps.custom_epg_id ===
newCustomProps.custom_epg_id === undefined undefined &&
!newCustomProps.force_dummy_epg
) { ) {
// Migrate: force_dummy_epg=true becomes custom_epg_id=null // Default to "No EPG (Disabled)"
newCustomProps.custom_epg_id = null; newCustomProps.force_dummy_epg = true;
delete newCustomProps.force_dummy_epg;
} else if (
newCustomProps.custom_epg_id === undefined
) {
// New configuration: initialize with null (no EPG/default dummy)
newCustomProps.custom_epg_id = null;
} }
} else { } else {
// Only remove custom_epg_id when deselected // Remove all EPG settings when deselected
delete newCustomProps.custom_epg_id; delete newCustomProps.custom_epg_id;
delete newCustomProps.force_dummy_epg;
delete newCustomProps.force_epg_selected;
} }
// Handle group_override // Handle group_override
@ -1124,7 +1122,8 @@ const LiveGroupFilter = ({
{/* Show EPG selector when force_epg is selected */} {/* Show EPG selector when force_epg is selected */}
{(group.custom_properties?.custom_epg_id !== undefined || {(group.custom_properties?.custom_epg_id !== undefined ||
group.custom_properties?.force_dummy_epg) && ( group.custom_properties?.force_dummy_epg ||
group.custom_properties?.force_epg_selected) && (
<Tooltip <Tooltip
label="Force a specific EPG source for all auto-synced channels in this group. For dummy EPGs, all channels will share the same EPG data. For regular EPG sources (XMLTV, Schedules Direct), channels will be matched by their tvg_id within that source. Select 'No EPG' to disable EPG assignment." label="Force a specific EPG source for all auto-synced channels in this group. For dummy EPGs, all channels will share the same EPG data. For regular EPG sources (XMLTV, Schedules Direct), channels will be matched by their tvg_id within that source. Select 'No EPG' to disable EPG assignment."
withArrow withArrow
@ -1133,44 +1132,90 @@ const LiveGroupFilter = ({
label="EPG Source" label="EPG Source"
placeholder="No EPG (Disabled)" placeholder="No EPG (Disabled)"
value={(() => { value={(() => {
// Handle migration from force_dummy_epg // Show custom EPG if set
if ( if (
group.custom_properties?.custom_epg_id !== group.custom_properties?.custom_epg_id !==
undefined undefined &&
group.custom_properties?.custom_epg_id !== null
) { ) {
// Convert to string, use '0' for null/no EPG return group.custom_properties.custom_epg_id.toString();
return group.custom_properties.custom_epg_id === }
null // Show "No EPG" if force_dummy_epg is set
? '0' if (group.custom_properties?.force_dummy_epg) {
: group.custom_properties.custom_epg_id.toString();
} else if (
group.custom_properties?.force_dummy_epg
) {
// Show "No EPG" for old force_dummy_epg configs
return '0'; return '0';
} }
return '0'; // Otherwise show empty/placeholder
return null;
})()} })()}
onChange={(value) => { onChange={(value) => {
// Convert back: '0' means no EPG (null) if (value === '0') {
const newValue = // "No EPG (Disabled)" selected - use force_dummy_epg
value === '0' ? null : parseInt(value); setGroupStates(
setGroupStates( groupStates.map((state) => {
groupStates.map((state) => { if (
if ( state.channel_group ===
state.channel_group === group.channel_group group.channel_group
) { ) {
return { const newProps = {
...state,
custom_properties: {
...state.custom_properties, ...state.custom_properties,
custom_epg_id: newValue, };
}, delete newProps.custom_epg_id;
}; delete newProps.force_epg_selected;
} newProps.force_dummy_epg = true;
return state; return {
}) ...state,
); custom_properties: newProps,
};
}
return state;
})
);
} else if (value) {
// Specific EPG source selected
const epgId = parseInt(value);
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
const newProps = {
...state.custom_properties,
};
newProps.custom_epg_id = epgId;
delete newProps.force_dummy_epg;
delete newProps.force_epg_selected;
return {
...state,
custom_properties: newProps,
};
}
return state;
})
);
} else {
// Cleared - remove all EPG settings
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
const newProps = {
...state.custom_properties,
};
delete newProps.custom_epg_id;
delete newProps.force_dummy_epg;
delete newProps.force_epg_selected;
return {
...state,
custom_properties: newProps,
};
}
return state;
})
);
}
}} }}
data={[ data={[
{ value: '0', label: 'No EPG (Disabled)' }, { value: '0', label: 'No EPG (Disabled)' },

View file

@ -0,0 +1,110 @@
import React from 'react';
import { Modal, Flex, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../pages/guideUtils.js';
export default function ProgramRecordingModal({
opened,
onClose,
program,
recording,
existingRuleMode,
onRecordOne,
onRecordSeriesAll,
onRecordSeriesNew,
onExistingRuleModeChange,
}) {
const handleRemoveRecording = async () => {
try {
await deleteRecordingById(recording.id);
} catch (error) {
console.warn('Failed to delete recording', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after delete', error);
}
onClose();
};
const handleRemoveSeries = async () => {
await deleteSeriesAndRule({
tvg_id: program.tvg_id,
title: program.title,
});
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after series delete', error);
}
onClose();
};
const handleRemoveSeriesRule = async () => {
await deleteSeriesRuleByTvgId(program.tvg_id);
onExistingRuleModeChange(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={`Record: ${program?.title}`}
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' },
}}
>
<Flex direction="column" gap="sm">
<Button
onClick={() => {
onRecordOne();
onClose();
}}
>
Just this one
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesAll();
onClose();
}}>
Every episode
</Button>
<Button variant="light" onClick={() => {
onRecordSeriesNew();
onClose();
}}>
New episodes only
</Button>
{recording && (
<>
<Button color="orange" variant="light" onClick={handleRemoveRecording}>
Remove this recording
</Button>
<Button color="red" variant="light" onClick={handleRemoveSeries}>
Remove this series (scheduled)
</Button>
</>
)}
{existingRuleMode && (
<Button color="red" variant="subtle" onClick={handleRemoveSeriesRule}>
Remove series rule ({existingRuleMode})
</Button>
)}
</Flex>
</Modal>
);
}

View file

@ -0,0 +1,91 @@
import React from 'react';
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
export default function SeriesRecordingModal({
opened,
onClose,
rules,
onRulesUpdate
}) {
const handleEvaluateNow = async (r) => {
await evaluateSeriesRulesByTvgId(r.tvg_id);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after evaluation', error);
}
showNotification({
title: 'Evaluated',
message: 'Checked for episodes',
});
};
const handleRemoveSeries = async (r) => {
await deleteSeriesAndRule({ tvg_id: r.tvg_id, title: r.title });
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after bulk removal', error);
}
const updated = await fetchRules();
onRulesUpdate(updated);
};
return (
<Modal
opened={opened}
onClose={onClose}
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' },
}}
>
<Stack gap="sm">
{(!rules || rules.length === 0) && (
<Text size="sm" c="dimmed">
No series rules configured
</Text>
)}
{rules && rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
);
}

View file

@ -0,0 +1,263 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import {
getChangedSettings,
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
Alert,
Button,
FileInput,
Flex,
Group,
NumberInput,
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import {
getComskipConfig,
getDvrSettingsFormInitialValues,
uploadComskipIni,
} from '../../../utils/forms/settings/DvrSettingsFormUtils.js';
import { useForm } from '@mantine/form';
const DvrSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const [comskipFile, setComskipFile] = useState(null);
const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
const [comskipConfig, setComskipConfig] = useState({
path: '',
exists: false,
});
const form = useForm({
mode: 'controlled',
initialValues: getDvrSettingsFormInitialValues(),
});
useEffect(() => {
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
const formValues = parseSettings(settings);
form.setValues(formValues);
if (formValues['dvr-comskip-custom-path']) {
setComskipConfig((prev) => ({
path: formValues['dvr-comskip-custom-path'],
exists: prev.exists,
}));
}
}
}, [settings]);
useEffect(() => {
const loadComskipConfig = async () => {
try {
const response = await getComskipConfig();
if (response) {
setComskipConfig({
path: response.path || '',
exists: Boolean(response.exists),
});
if (response.path) {
form.setFieldValue('dvr-comskip-custom-path', response.path);
}
}
} catch (error) {
console.error('Failed to load comskip config', error);
}
};
loadComskipConfig();
}, []);
const onComskipUpload = async () => {
if (!comskipFile) {
return;
}
setComskipUploadLoading(true);
try {
const response = await uploadComskipIni(comskipFile);
if (response?.path) {
showNotification({
title: 'comskip.ini uploaded',
message: response.path,
autoClose: 3000,
color: 'green',
});
form.setFieldValue('dvr-comskip-custom-path', response.path);
useSettingsStore.getState().updateSetting({
...(settings['dvr-comskip-custom-path'] || {
key: 'dvr-comskip-custom-path',
name: 'DVR Comskip Custom Path',
}),
value: response.path,
});
setComskipConfig({ path: response.path, exists: true });
}
} catch (error) {
console.error('Failed to upload comskip.ini', error);
} finally {
setComskipUploadLoading(false);
setComskipFile(null);
}
};
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 (
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="sm">
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<Switch
label="Enable Comskip (remove commercials after recording)"
{...form.getInputProps('dvr-comskip-enabled', {
type: 'checkbox',
})}
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
/>
<TextInput
label="Custom comskip.ini path"
description="Leave blank to use the built-in defaults."
placeholder="/app/docker/comskip.ini"
{...form.getInputProps('dvr-comskip-custom-path')}
id={
settings['dvr-comskip-custom-path']?.id || 'dvr-comskip-custom-path'
}
name={
settings['dvr-comskip-custom-path']?.key ||
'dvr-comskip-custom-path'
}
/>
<Group align="flex-end" gap="sm">
<FileInput
placeholder="Select comskip.ini"
accept=".ini"
value={comskipFile}
onChange={setComskipFile}
clearable
disabled={comskipUploadLoading}
flex={1}
/>
<Button
variant="light"
onClick={onComskipUpload}
disabled={!comskipFile || comskipUploadLoading}
>
{comskipUploadLoading ? 'Uploading...' : 'Upload comskip.ini'}
</Button>
</Group>
<Text size="xs" c="dimmed">
{comskipConfig.exists && comskipConfig.path
? `Using ${comskipConfig.path}`
: 'No custom comskip.ini uploaded.'}
</Text>
<NumberInput
label="Start early (minutes)"
description="Begin recording this many minutes before the scheduled start."
min={0}
step={1}
{...form.getInputProps('dvr-pre-offset-minutes')}
id={
settings['dvr-pre-offset-minutes']?.id || 'dvr-pre-offset-minutes'
}
name={
settings['dvr-pre-offset-minutes']?.key || 'dvr-pre-offset-minutes'
}
/>
<NumberInput
label="End late (minutes)"
description="Continue recording this many minutes after the scheduled end."
min={0}
step={1}
{...form.getInputProps('dvr-post-offset-minutes')}
id={
settings['dvr-post-offset-minutes']?.id || 'dvr-post-offset-minutes'
}
name={
settings['dvr-post-offset-minutes']?.key ||
'dvr-post-offset-minutes'
}
/>
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
/>
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
id={
settings['dvr-tv-fallback-template']?.id ||
'dvr-tv-fallback-template'
}
name={
settings['dvr-tv-fallback-template']?.key ||
'dvr-tv-fallback-template'
}
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
/>
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
id={
settings['dvr-movie-fallback-template']?.id ||
'dvr-movie-fallback-template'
}
name={
settings['dvr-movie-fallback-template']?.key ||
'dvr-movie-fallback-template'
}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default">
Save
</Button>
</Flex>
</Stack>
</form>
);
});
export default DvrSettingsForm;

View file

@ -0,0 +1,161 @@
import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import {
checkSetting,
updateSetting,
} from '../../../utils/pages/SettingsUtils.js';
import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core';
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import {
getNetworkAccessFormInitialValues,
getNetworkAccessFormValidation,
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
const NetworkAccessForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [networkAccessError, setNetworkAccessError] = useState(null);
const [saved, setSaved] = useState(false);
const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
useState(false);
const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
useState([]);
const [clientIpAddress, setClientIpAddress] = useState(null);
const networkAccessForm = useForm({
mode: 'controlled',
initialValues: getNetworkAccessFormInitialValues(),
validate: getNetworkAccessFormValidation(),
});
useEffect(() => {
if(!active) setSaved(false);
}, [active]);
useEffect(() => {
const networkAccessSettings = JSON.parse(
settings['network-access'].value || '{}'
);
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
return acc;
}, {})
);
}, [settings]);
const onNetworkAccessSubmit = async () => {
setSaved(false);
setNetworkAccessError(null);
const check = await checkSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
});
if (check.error && check.message) {
setNetworkAccessError(`${check.message}: ${check.data}`);
return;
}
// Store the client IP
setClientIpAddress(check.client_ip);
// For now, only warn if we're blocking the UI
const blockedAccess = check.UI;
if (blockedAccess.length === 0) {
return saveNetworkAccess();
}
setNetNetworkAccessConfirmCIDRs(blockedAccess);
setNetworkAccessConfirmOpen(true);
};
const saveNetworkAccess = async () => {
setSaved(false);
try {
await updateSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
});
setSaved(true);
setNetworkAccessConfirmOpen(false);
} catch (e) {
const errors = {};
for (const key in e.body.value) {
errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`;
}
networkAccessForm.setErrors(errors);
}
};
return (
<>
<form onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}>
<Stack gap="sm">
{saved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
)}
{networkAccessError && (
<Alert
variant="light"
color="red"
title={networkAccessError}
></Alert>
)}
{Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => (
<TextInput
label={config.label}
{...networkAccessForm.getInputProps(key)}
key={networkAccessForm.key(key)}
description={config.description}
/>
))}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
disabled={networkAccessForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
<ConfirmationDialog
opened={networkAccessConfirmOpen}
onClose={() => setNetworkAccessConfirmOpen(false)}
onConfirm={saveNetworkAccess}
title={`Confirm Network Access Blocks`}
message={
<>
<Text>
Your client {clientIpAddress && `(${clientIpAddress}) `}is not
included in the allowed networks for the web UI. Are you sure you
want to proceed?
</Text>
<ul>
{netNetworkAccessConfirmCIDRs.map((cidr) => (
<li>{cidr}</li>
))}
</ul>
</>
}
confirmLabel="Save"
cancelLabel="Cancel"
size="md"
/>
</>
);
});
export default NetworkAccessForm;

View file

@ -0,0 +1,166 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
Flex,
NumberInput,
Stack,
TextInput,
} from '@mantine/core';
import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getProxySettingDefaults,
getProxySettingsFormInitialValues,
} from '../../../utils/forms/settings/ProxySettingsFormUtils.js';
const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => {
const isNumericField = (key) => {
// Determine if this field should be a NumberInput
return [
'buffering_timeout',
'redis_chunk_ttl',
'channel_shutdown_delay',
'channel_init_grace_period',
].includes(key);
};
const isFloatField = (key) => {
return key === 'buffering_speed';
};
const getNumericFieldMax = (key) => {
return key === 'buffering_timeout'
? 300
: key === 'redis_chunk_ttl'
? 3600
: key === 'channel_shutdown_delay'
? 300
: 60;
};
return (
<>
{Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => {
if (isNumericField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={getNumericFieldMax(key)}
/>
);
} else if (isFloatField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
} else {
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
}
})}
</>
);
});
const ProxySettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const proxySettingsForm = useForm({
mode: 'controlled',
initialValues: getProxySettingsFormInitialValues(),
});
useEffect(() => {
if(!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
if (settings['proxy-settings']?.value) {
try {
const proxySettings = JSON.parse(settings['proxy-settings'].value);
proxySettingsForm.setValues(proxySettings);
} catch (error) {
console.error('Error parsing proxy settings:', error);
}
}
}
}, [settings]);
const resetProxySettingsToDefaults = () => {
proxySettingsForm.setValues(getProxySettingDefaults());
};
const onProxySettingsSubmit = async () => {
setSaved(false);
try {
const result = await updateSetting({
...settings['proxy-settings'],
value: JSON.stringify(proxySettingsForm.getValues()),
});
// API functions return undefined on error
if (result) {
setSaved(true);
}
} catch (error) {
// Error notifications are already shown by API functions
console.error('Error saving proxy settings:', error);
}
};
return (
<form onSubmit={proxySettingsForm.onSubmit(onProxySettingsSubmit)}>
<Stack gap="sm">
{saved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
)}
<ProxySettingsOptions proxySettingsForm={proxySettingsForm} />
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button
variant="subtle"
color="gray"
onClick={resetProxySettingsToDefaults}
>
Reset to Defaults
</Button>
<Button
type="submit"
disabled={proxySettingsForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
);
});
export default ProxySettingsForm;

View file

@ -0,0 +1,306 @@
import useSettingsStore from '../../../store/settings.jsx';
import useWarningsStore from '../../../store/warnings.jsx';
import useUserAgentsStore from '../../../store/userAgents.jsx';
import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
import { REGION_CHOICES } from '../../../constants.js';
import React, { useEffect, useState } from 'react';
import {
getChangedSettings,
parseSettings,
rehashStreams,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
Flex,
Group,
MultiSelect,
Select,
Switch,
Text,
} from '@mantine/core';
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import { useForm } from '@mantine/form';
import {
getStreamSettingsFormInitialValues,
getStreamSettingsFormValidation,
} from '../../../utils/forms/settings/StreamSettingsFormUtils.js';
const StreamSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const userAgents = useUserAgentsStore((s) => s.userAgents);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const regionChoices = REGION_CHOICES;
// Store pending changed settings when showing the dialog
const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
const [saved, setSaved] = useState(false);
const [rehashingStreams, setRehashingStreams] = useState(false);
const [rehashSuccess, setRehashSuccess] = useState(false);
const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
// Add a new state to track the dialog type
const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
const form = useForm({
mode: 'controlled',
initialValues: getStreamSettingsFormInitialValues(),
validate: getStreamSettingsFormValidation(),
});
useEffect(() => {
if (!active) {
setSaved(false);
setRehashSuccess(false);
}
}, [active]);
useEffect(() => {
if (settings) {
const formValues = parseSettings(settings);
form.setValues(formValues);
}
}, [settings]);
const executeSettingsSaveAndRehash = async () => {
setRehashConfirmOpen(false);
setSaved(false);
// Use the stored pending values that were captured before the dialog was shown
const changedSettings = pendingChangedSettings || {};
// Update each changed setting in the backend (create if missing)
try {
await saveChangedSettings(settings, changedSettings);
// Clear the pending values
setPendingChangedSettings(null);
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);
setPendingChangedSettings(null);
}
};
const executeRehashStreamsOnly = async () => {
setRehashingStreams(true);
setRehashSuccess(false);
setRehashConfirmOpen(false);
try {
await rehashStreams();
setRehashSuccess(true);
setTimeout(() => setRehashSuccess(false), 5000);
} catch (error) {
console.error('Error rehashing streams:', error);
} finally {
setRehashingStreams(false);
}
};
const onRehashStreams = async () => {
// Skip warning if it's been suppressed
if (isWarningSuppressed('rehash-streams')) {
return executeRehashStreamsOnly();
}
setRehashDialogType('rehash'); // Set dialog type to rehash
setRehashConfirmOpen(true);
};
const handleRehashConfirm = () => {
if (rehashDialogType === 'save') {
executeSettingsSaveAndRehash();
} else {
executeRehashStreamsOnly();
}
};
const onSubmit = async () => {
setSaved(false);
const values = form.getValues();
const changedSettings = getChangedSettings(values, settings);
const m3uHashKeyChanged =
settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(',');
// If M3U hash key changed, show warning (unless suppressed)
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
// Store the changed settings before showing dialog
setPendingChangedSettings(changedSettings);
setRehashDialogType('save'); // Set dialog type to save
setRehashConfirmOpen(true);
return;
}
// 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 (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<Select
searchable
{...form.getInputProps('default-user-agent')}
id={settings['default-user-agent']?.id || 'default-user-agent'}
name={settings['default-user-agent']?.key || 'default-user-agent'}
label={settings['default-user-agent']?.name || 'Default User Agent'}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
id={
settings['default-stream-profile']?.id || 'default-stream-profile'
}
name={
settings['default-stream-profile']?.key || 'default-stream-profile'
}
label={
settings['default-stream-profile']?.name || 'Default Stream Profile'
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Group justify="space-between" pt={5}>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
type: 'checkbox',
})}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
/>
</Group>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
label="M3U Hash Key"
data={[
{
value: 'name',
label: 'Name',
},
{
value: 'url',
label: 'URL',
},
{
value: 'tvg_id',
label: 'TVG-ID',
},
{
value: 'm3u_id',
label: 'M3U ID',
},
{
value: 'group',
label: 'Group',
},
]}
{...form.getInputProps('m3u-hash-key')}
/>
{rehashSuccess && (
<Alert
variant="light"
color="green"
title="Rehash task queued successfully"
/>
)}
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button
onClick={onRehashStreams}
loading={rehashingStreams}
variant="outline"
color="blue"
>
Rehash Streams
</Button>
<Button type="submit" disabled={form.submitting} variant="default">
Save
</Button>
</Flex>
</form>
<ConfirmationDialog
opened={rehashConfirmOpen}
onClose={() => {
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={
<div style={{ whiteSpace: 'pre-line' }}>
{`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.`}
</div>
}
confirmLabel={
rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
}
cancelLabel="Cancel"
actionKey="rehash-streams"
onSuppressChange={suppressWarning}
size="md"
/>
</>
);
});
export default StreamSettingsForm;

View file

@ -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 (
<Stack gap="md">
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<Text size="sm" c="dimmed">
Configure how many system events (channel start/stop, buffering, etc.)
to keep in the database. Events are displayed on the Stats page.
</Text>
<NumberInput
label="Maximum System Events"
description="Number of events to retain (minimum: 10, maximum: 1000)"
value={form.values['max-system-events'] || 100}
onChange={(value) => {
form.setFieldValue('max-system-events', value);
}}
min={10}
max={1000}
step={10}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
onClick={form.onSubmit(onSubmit)}
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
);
});
export default SystemSettingsForm;

View file

@ -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 (
<>
<Select
label="Table Size"
value={tableSize}
onChange={(val) => onUISettingsChange('table-size', val)}
data={[
{
value: 'default',
label: 'Default',
},
{
value: 'compact',
label: 'Compact',
},
{
value: 'large',
label: 'Large',
},
]}
/>
<Select
label="Time format"
value={timeFormat}
onChange={(val) => onUISettingsChange('time-format', val)}
data={[
{
value: '12h',
label: '12 hour time',
},
{
value: '24h',
label: '24 hour time',
},
]}
/>
<Select
label="Date format"
value={dateFormat}
onChange={(val) => onUISettingsChange('date-format', val)}
data={[
{
value: 'mdy',
label: 'MM/DD/YYYY',
},
{
value: 'dmy',
label: 'DD/MM/YYYY',
},
]}
/>
<Select
label="Time zone"
searchable
nothingFoundMessage="No matches"
value={timeZone}
onChange={(val) => onUISettingsChange('time-zone', val)}
data={timeZoneOptions}
/>
</>
);
});
export default UiSettingsForm;

View file

@ -303,6 +303,7 @@ export const REGION_CHOICES = [
{ value: 'tz', label: 'TZ' }, { value: 'tz', label: 'TZ' },
{ value: 'ua', label: 'UA' }, { value: 'ua', label: 'UA' },
{ value: 'ug', label: 'UG' }, { value: 'ug', label: 'UG' },
{ value: 'uk', label: 'UK' },
{ value: 'um', label: 'UM' }, { value: 'um', label: 'UM' },
{ value: 'us', label: 'US' }, { value: 'us', label: 'US' },
{ value: 'uy', label: 'UY' }, { value: 'uy', label: 'UY' },

View file

@ -65,6 +65,7 @@ const PageContent = () => {
if (!authUser.id) return <></>; if (!authUser.id) return <></>;
if (authUser.user_level <= USER_LEVELS.STANDARD) { if (authUser.user_level <= USER_LEVELS.STANDARD) {
handleStreamsReady();
return ( return (
<Box style={{ padding: 10 }}> <Box style={{ padding: 10 }}>
<ChannelsTable onReady={handleChannelsReady} /> <ChannelsTable onReady={handleChannelsReady} />

View file

@ -18,15 +18,29 @@ import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore'; import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording'; import RecordingForm from '../components/forms/Recording';
import { import {
isAfter,
isBefore,
useTimeHelpers, useTimeHelpers,
} from '../utils/dateTimeUtils.js'; } 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 RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
import RecordingCard from '../components/cards/RecordingCard.jsx'; import RecordingCard from '../components/cards/RecordingCard.jsx';
import { categorizeRecordings } from '../utils/pages/DVRUtils.js'; 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'; import ErrorBoundary from '../components/ErrorBoundary.jsx';
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
return list.map((rec) => (
<RecordingCard
key={`rec-${rec.id}`}
recording={rec}
onOpenDetails={onOpenDetails}
onOpenRecurring={onOpenRecurring}
/>
));
};
const DVRPage = () => { const DVRPage = () => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const recordings = useChannelsStore((s) => s.recordings); const recordings = useChannelsStore((s) => s.recordings);
@ -94,46 +108,25 @@ const DVRPage = () => {
return categorizeRecordings(recordings, toUserTime, now); return categorizeRecordings(recordings, toUserTime, now);
}, [recordings, now, toUserTime]); }, [recordings, now, toUserTime]);
const RecordingList = ({ list }) => {
return list.map((rec) => (
<RecordingCard
key={`rec-${rec.id}`}
recording={rec}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>
));
};
const handleOnWatchLive = () => { const handleOnWatchLive = () => {
const rec = detailsRecording; const rec = detailsRecording;
const now = userNow(); const now = userNow();
const s = toUserTime(rec.start_time); const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_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 // call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel]; const channel = channels[rec.channel];
if (!channel) return; if (!channel) return;
let url = `/proxy/ts/stream/${channel.uuid}`; const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
if (useSettingsStore.getState().environment.env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
useVideoStore.getState().showVideo(url, 'live'); useVideoStore.getState().showVideo(url, 'live');
} }
} }
const handleOnWatchRecording = () => { const handleOnWatchRecording = () => {
let fileUrl = const url = getRecordingUrl(
detailsRecording.custom_properties?.file_url || detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode);
detailsRecording.custom_properties?.output_file_url; if(!url) return;
if (!fileUrl) return; useVideoStore.getState().showVideo(url, 'vod', {
if (
useSettingsStore.getState().environment.env_mode === 'dev' &&
fileUrl.startsWith('/')
) {
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
}
useVideoStore.getState().showVideo(fileUrl, 'vod', {
name: name:
detailsRecording.custom_properties?.program?.title || detailsRecording.custom_properties?.program?.title ||
'Recording', 'Recording',
@ -163,7 +156,7 @@ const DVRPage = () => {
> >
New Recording New Recording
</Button> </Button>
<Stack gap="lg" style={{ paddingTop: 12 }}> <Stack gap="lg" pt={12}>
<div> <div>
<Group justify="space-between" mb={8}> <Group justify="space-between" mb={8}>
<Title order={4}>Currently Recording</Title> <Title order={4}>Currently Recording</Title>
@ -177,7 +170,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 }, { maxWidth: '36rem', cols: 1 },
]} ]}
> >
{<RecordingList list={inProgress} />} {<RecordingList
list={inProgress}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{inProgress.length === 0 && ( {inProgress.length === 0 && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Nothing recording right now. Nothing recording right now.
@ -199,7 +196,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 }, { maxWidth: '36rem', cols: 1 },
]} ]}
> >
{<RecordingList list={upcoming} />} {<RecordingList
list={upcoming}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{upcoming.length === 0 && ( {upcoming.length === 0 && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No upcoming recordings. No upcoming recordings.
@ -221,7 +222,11 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 }, { maxWidth: '36rem', cols: 1 },
]} ]}
> >
{<RecordingList list={completed} />} {<RecordingList
list={completed}
onOpenDetails={openDetails}
onOpenRecurring={openRuleModal}
/>}
{completed.length === 0 && ( {completed.length === 0 && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No completed recordings yet. No completed recordings yet.

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,34 @@
import React, { useEffect, useCallback, useState } from 'react'; import React, { useEffect, useCallback, useState } from 'react';
import { Box, Tabs, Flex, Text } from '@mantine/core'; import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useLogosStore from '../store/logos'; import useLogosStore from '../store/logos';
import useVODLogosStore from '../store/vodLogos'; import useVODLogosStore from '../store/vodLogos';
import LogosTable from '../components/tables/LogosTable'; import LogosTable from '../components/tables/LogosTable';
import VODLogosTable from '../components/tables/VODLogosTable'; import VODLogosTable from '../components/tables/VODLogosTable';
import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => { const LogosPage = () => {
const { fetchAllLogos, needsAllLogos, logos } = useLogosStore(); const logos = useLogosStore(s => s.logos);
const { totalCount } = useVODLogosStore(); const totalCount = useVODLogosStore(s => s.totalCount);
const [activeTab, setActiveTab] = useState('channel'); const [activeTab, setActiveTab] = useState('channel');
const logoCount = activeTab === 'channel'
const channelLogosCount = Object.keys(logos).length; ? Object.keys(logos).length
const vodLogosCount = totalCount; : totalCount;
const loadChannelLogos = useCallback(async () => { const loadChannelLogos = useCallback(async () => {
try { try {
// Only fetch all logos if we haven't loaded them yet // Only fetch all logos if we haven't loaded them yet
if (needsAllLogos()) { if (useLogosStore.getState().needsAllLogos()) {
await fetchAllLogos(); await useLogosStore.getState().fetchAllLogos();
} }
} catch (err) { } catch (err) {
notifications.show({ showNotification({
title: 'Error', title: 'Error',
message: 'Failed to load channel logos', message: 'Failed to load channel logos',
color: 'red', color: 'red',
}); });
console.error('Failed to load channel logos:', err); console.error('Failed to load channel logos:', err);
} }
}, [fetchAllLogos, needsAllLogos]); }, []);
useEffect(() => { useEffect(() => {
// Always load channel logos on mount // Always load channel logos on mount
@ -39,51 +39,41 @@ const LogosPage = () => {
<Box> <Box>
{/* Header with title and tabs */} {/* Header with title and tabs */}
<Box <Box
style={{ style={{ justifyContent: 'center' }}
display: 'flex', display={'flex'}
justifyContent: 'center', p={'10px 0'}
padding: '10px 0',
}}
> >
<Flex <Flex
style={{ style={{
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
width: '100%',
maxWidth: '1200px',
paddingBottom: 10,
}} }}
w={'100%'}
maw={'1200px'}
pb={10}
> >
<Flex gap={8} align="center"> <Flex gap={8} align="center">
<Text <Text
style={{ ff={'Inter, sans-serif'}
fontFamily: 'Inter, sans-serif', fz={'20px'}
fontWeight: 500, fw={500}
fontSize: '20px', lh={1}
lineHeight: 1, c='white'
letterSpacing: '-0.3px', mb={0}
color: 'gray.6', lts={'-0.3px'}
marginBottom: 0,
}}
> >
Logos Logos
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '} ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
logo
{(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !==
1
? 's'
: ''}
)
</Text> </Text>
</Flex> </Flex>
<Tabs value={activeTab} onChange={setActiveTab} variant="pills"> <Tabs value={activeTab} onChange={setActiveTab} variant="pills">
<Tabs.List> <TabsList>
<Tabs.Tab value="channel">Channel Logos</Tabs.Tab> <TabsTab value="channel">Channel Logos</TabsTab>
<Tabs.Tab value="vod">VOD Logos</Tabs.Tab> <TabsTab value="vod">VOD Logos</TabsTab>
</Tabs.List> </TabsList>
</Tabs> </Tabs>
</Flex> </Flex>
</Box> </Box>

View file

@ -1,353 +1,108 @@
import React, { useEffect, useState } from 'react'; import React, {
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { import {
AppShell, ActionIcon,
Box,
Alert, Alert,
AppShellMain,
Box,
Button, Button,
Card, Divider,
FileInput,
Group, Group,
Loader, Loader,
Modal,
SimpleGrid,
Stack, Stack,
Switch, Switch,
Text, Text,
TextInput,
NumberInput,
Select,
Divider,
ActionIcon,
SimpleGrid,
Modal,
FileInput,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { RefreshCcw, Trash2 } from 'lucide-react'; import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
import API from '../api'; import { usePluginStore } from '../store/plugins.jsx';
import { notifications } from '@mantine/notifications'; 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 PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const common = { label: field.label, description: field.help_text }; const plugins = usePluginStore((state) => state.plugins);
const effective = value ?? field.default; const loading = usePluginStore((state) => state.loading);
switch (field.type) { const hasFetchedRef = useRef(false);
case 'boolean':
return (
<Switch
checked={!!effective}
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
label={field.label}
description={field.help_text}
/>
);
case 'number':
return (
<NumberInput
value={value ?? field.default ?? 0}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'select':
return (
<Select
value={(value ?? field.default ?? '') + ''}
data={(field.options || []).map((o) => ({
value: o.value + '',
label: o.label,
}))}
onChange={(v) => onChange(field.id, v)}
{...common}
/>
);
case 'string':
default:
return (
<TextInput
value={value ?? field.default ?? ''}
onChange={(e) => onChange(field.id, e.currentTarget.value)}
{...common}
/>
);
}
};
const PluginCard = ({ useEffect(() => {
plugin, if (!hasFetchedRef.current) {
onSaveSettings, hasFetchedRef.current = true;
onRunAction, usePluginStore.getState().fetchPlugins();
onToggleEnabled, }
onRequireTrust, }, []);
onRequestDelete,
}) => {
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmConfig, setConfirmConfig] = useState({
title: '',
message: '',
onConfirm: null,
});
// Keep local enabled state in sync with props (e.g., after import + enable) const handleTogglePluginEnabled = async (key, next) => {
React.useEffect(() => { const resp = await setPluginEnabled(key, next);
setEnabled(!!plugin.enabled);
}, [plugin.enabled]);
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key]);
const updateField = (id, val) => { if (resp?.success) {
setSettings((prev) => ({ ...prev, [id]: val })); usePluginStore.getState().updatePlugin(key, {
}; enabled: next,
ever_enabled: resp?.ever_enabled,
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
notifications.show({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
}); });
} finally {
setSaving(false);
} }
}; };
const missing = plugin.missing; if (loading && plugins.length === 0) {
return <Loader />;
}
return ( return (
<Card <>
shadow="sm" {plugins.length > 0 &&
radius="md" <SimpleGrid
withBorder cols={2}
style={{ opacity: !missing && enabled ? 1 : 0.6 }} spacing="md"
> breakpoints={[{ maxWidth: '48em', cols: 1 }]}
<Group justify="space-between" mb="xs" align="center"> >
<div> <ErrorBoundary>
<Text fw={600}>{plugin.name}</Text> <Suspense fallback={<Loader />}>
<Text size="sm" c="dimmed"> {plugins.map((p) => (
{plugin.description} <PluginCard
key={p.key}
plugin={p}
onSaveSettings={updatePluginSettings}
onRunAction={runPluginAction}
onToggleEnabled={handleTogglePluginEnabled}
onRequireTrust={onRequireTrust}
onRequestDelete={onRequestDelete}
onRequestConfirm={onRequestConfirm}
/>
))}
</Suspense>
</ErrorBoundary>
</SimpleGrid>
}
{plugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
</Text> </Text>
</div> </Box>
<Group gap="xs" align="center">
<ActionIcon
variant="subtle"
color="red"
title="Delete plugin"
onClick={() => onRequestDelete && onRequestDelete(plugin)}
>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">
v{plugin.version || '1.0.0'}
</Text>
<Switch
checked={!missing && enabled}
onChange={async (e) => {
const next = e.currentTarget.checked;
if (next && !plugin.ever_enabled && onRequireTrust) {
const ok = await onRequireTrust(plugin);
if (!ok) {
// Revert
setEnabled(false);
return;
}
}
setEnabled(next);
const resp = await onToggleEnabled(plugin.key, next);
if (next && resp?.ever_enabled) {
plugin.ever_enabled = true;
}
}}
size="xs"
onLabel="On"
offLabel="Off"
disabled={missing}
/>
</Group>
</Group>
{missing && (
<Text size="sm" c="red">
Missing plugin files. Re-import or delete this entry.
</Text>
)} )}
</>
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
{plugin.fields.map((f) => (
<Field
key={f.id}
field={f}
value={settings?.[f.id]}
onChange={updateField}
/>
))}
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
{!missing && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
{plugin.actions.map((a) => (
<Group key={a.id} justify="space-between">
<div>
<Text>{a.label}</Text>
{a.description && (
<Text size="sm" c="dimmed">
{a.description}
</Text>
)}
</div>
<Button
loading={running}
disabled={!enabled}
onClick={async () => {
setRunning(true);
setLastResult(null);
try {
// Determine if confirmation is required from action metadata or fallback field
const actionConfirm = a.confirm;
const confirmField = (plugin.fields || []).find(
(f) => f.id === 'confirm'
);
let requireConfirm = false;
let confirmTitle = `Run ${a.label}?`;
let confirmMessage = `You're about to run "${a.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;
}
if (requireConfirm) {
await new Promise((resolve) => {
setConfirmConfig({
title: confirmTitle,
message: confirmMessage,
onConfirm: resolve,
});
setConfirmOpen(true);
});
}
// Save settings before running to ensure backend uses latest values
try {
await onSaveSettings(plugin.key, settings);
} catch (e) {
/* ignore, run anyway */
}
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
const msg =
resp.result?.message || 'Plugin action completed';
notifications.show({
title: plugin.name,
message: msg,
color: 'green',
});
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
notifications.show({
title: `${plugin.name} error`,
message: String(err),
color: 'red',
});
}
} finally {
setRunning(false);
}
}}
size="xs"
>
{running ? 'Running…' : 'Run'}
</Button>
</Group>
))}
{running && (
<Text size="sm" c="dimmed">
Running action please wait
</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">
Output: {lastResult.file}
</Text>
)}
{!running && lastResult?.error && (
<Text size="sm" c="red">
Error: {String(lastResult.error)}
</Text>
)}
</Stack>
</>
)}
<Modal
opened={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
}}
title={confirmConfig.title}
centered
>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>
<Group justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => {
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
}}
>
Cancel
</Button>
<Button
size="xs"
onClick={() => {
const cb = confirmConfig.onConfirm;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', onConfirm: null });
cb && cb(true);
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Card>
); );
}; };
export default function PluginsPage() { export default function PluginsPage() {
const [loading, setLoading] = useState(true);
const [plugins, setPlugins] = useState([]);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null); const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@ -358,118 +113,172 @@ export default function PluginsPage() {
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [uploadNoticeId, setUploadNoticeId] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmConfig, setConfirmConfig] = useState({
title: '',
message: '',
resolve: null,
});
const load = async () => { const handleReload = () => {
setLoading(true); usePluginStore.getState().invalidatePlugins();
try {
const list = await API.getPlugins();
setPlugins(list);
} finally {
setLoading(false);
}
}; };
useEffect(() => { const handleRequestDelete = useCallback((pl) => {
load(); setDeleteTarget(pl);
setDeleteOpen(true);
}, []); }, []);
const requireTrust = (plugin) => { const requireTrust = useCallback((plugin) => {
return new Promise((resolve) => { return new Promise((resolve) => {
setTrustResolve(() => resolve); setTrustResolve(() => resolve);
setTrustOpen(true); setTrustOpen(true);
}); });
}, []);
const showImportForm = useCallback(() => {
setImportOpen(true);
setImported(null);
setImportFile(null);
setEnableAfterImport(false);
}, []);
const requestConfirm = useCallback((title, message) => {
return new Promise((resolve) => {
setConfirmConfig({ title, message, resolve });
setConfirmOpen(true);
});
}, []);
const handleImportPlugin = () => {
return async () => {
setImporting(true);
const id = showNotification({
title: 'Uploading plugin',
message: 'Backend may restart; please wait…',
loading: true,
autoClose: false,
withCloseButton: false,
});
try {
const resp = await importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
usePluginStore.getState().invalidatePlugins();
updateNotification({
id,
loading: false,
color: 'green',
title: 'Imported',
message:
'Plugin imported. If the app briefly disconnected, it should be back now.',
autoClose: 3000,
});
} else {
updateNotification({
id,
loading: false,
color: 'red',
title: 'Import failed',
message: resp?.error || 'Unknown error',
autoClose: 5000,
});
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
updateNotification({
id,
loading: false,
color: 'red',
title: 'Import failed',
message:
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed',
autoClose: 5000,
});
} finally {
setImporting(false);
}
};
}; };
const handleEnablePlugin = () => {
return async () => {
if (!imported) return;
const proceed = imported.ever_enabled || (await requireTrust(imported));
if (proceed) {
const resp = await setPluginEnabled(imported.key, true);
if (resp?.success) {
usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
showNotification({
title: imported.name,
message: 'Plugin enabled',
color: 'green',
});
}
setImportOpen(false);
setImported(null);
setEnableAfterImport(false);
}
};
};
const handleDeletePlugin = () => {
return async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await deletePluginByKey(deleteTarget.key);
if (resp?.success) {
usePluginStore.getState().removePlugin(deleteTarget.key);
showNotification({
title: deleteTarget.name,
message: 'Plugin deleted',
color: 'green',
});
}
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
};
const handleConfirm = useCallback((confirmed) => {
const resolver = confirmConfig.resolve;
setConfirmOpen(false);
setConfirmConfig({ title: '', message: '', resolve: null });
if (resolver) resolver(confirmed);
}, [confirmConfig.resolve]);
return ( return (
<AppShell.Main style={{ padding: 16 }}> <AppShellMain p={16}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Text fw={700} size="lg"> <Text fw={700} size="lg">
Plugins Plugins
</Text> </Text>
<Group> <Group>
<Button <Button size="xs" variant="light" onClick={showImportForm}>
size="xs"
variant="light"
onClick={() => {
setImportOpen(true);
setImported(null);
setImportFile(null);
setEnableAfterImport(false);
}}
>
Import Plugin Import Plugin
</Button> </Button>
<ActionIcon <ActionIcon variant="light" onClick={handleReload} title="Reload">
variant="light"
onClick={async () => {
await API.reloadPlugins();
await load();
}}
title="Reload"
>
<RefreshCcw size={18} /> <RefreshCcw size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Group> </Group>
{loading ? ( <PluginsList
<Loader /> onRequestDelete={handleRequestDelete}
) : ( onRequireTrust={requireTrust}
<> onRequestConfirm={requestConfirm}
<SimpleGrid />
cols={2}
spacing="md"
verticalSpacing="md"
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
>
{plugins.map((p) => (
<PluginCard
key={p.key}
plugin={p}
onSaveSettings={API.updatePluginSettings}
onRunAction={API.runPluginAction}
onToggleEnabled={async (key, next) => {
const resp = await API.setPluginEnabled(key, next);
if (resp?.ever_enabled !== undefined) {
setPlugins((prev) =>
prev.map((pl) =>
pl.key === key
? {
...pl,
ever_enabled: resp.ever_enabled,
enabled: resp.enabled,
}
: pl
)
);
} else {
setPlugins((prev) =>
prev.map((pl) =>
pl.key === key ? { ...pl, enabled: next } : pl
)
);
}
return resp;
}}
onRequireTrust={requireTrust}
onRequestDelete={(plugin) => {
setDeleteTarget(plugin);
setDeleteOpen(true);
}}
/>
))}
</SimpleGrid>
{plugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
</Text>
</Box>
)}
</>
)}
{/* Import Plugin Modal */} {/* Import Plugin Modal */}
<Modal <Modal
opened={importOpen} opened={importOpen}
@ -520,61 +329,7 @@ export default function PluginsPage() {
size="xs" size="xs"
loading={importing} loading={importing}
disabled={!importFile} disabled={!importFile}
onClick={async () => { onClick={handleImportPlugin()}
setImporting(true);
const id = notifications.show({
title: 'Uploading plugin',
message: 'Backend may restart; please wait…',
loading: true,
autoClose: false,
withCloseButton: false,
});
setUploadNoticeId(id);
try {
const resp = await API.importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
setPlugins((prev) => [
resp.plugin,
...prev.filter((p) => p.key !== resp.plugin.key),
]);
notifications.update({
id,
loading: false,
color: 'green',
title: 'Imported',
message:
'Plugin imported. If the app briefly disconnected, it should be back now.',
autoClose: 3000,
});
} else {
notifications.update({
id,
loading: false,
color: 'red',
title: 'Import failed',
message: resp?.error || 'Unknown error',
autoClose: 5000,
});
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
notifications.update({
id,
loading: false,
color: 'red',
title: 'Import failed',
message:
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed',
autoClose: 5000,
});
} finally {
setImporting(false);
setUploadNoticeId(null);
}
}}
> >
Upload Upload
</Button> </Button>
@ -612,36 +367,7 @@ export default function PluginsPage() {
<Button <Button
size="xs" size="xs"
disabled={!enableAfterImport} disabled={!enableAfterImport}
onClick={async () => { onClick={handleEnablePlugin()}
if (!imported) return;
let proceed = true;
if (!imported.ever_enabled) {
proceed = await requireTrust(imported);
}
if (proceed) {
const resp = await API.setPluginEnabled(
imported.key,
true
);
if (resp?.success) {
setPlugins((prev) =>
prev.map((p) =>
p.key === imported.key
? { ...p, enabled: true, ever_enabled: true }
: p
)
);
notifications.show({
title: imported.name,
message: 'Plugin enabled',
color: 'green',
});
}
setImportOpen(false);
setImported(null);
setEnableAfterImport(false);
}
}}
> >
Enable Enable
</Button> </Button>
@ -727,33 +453,37 @@ export default function PluginsPage() {
size="xs" size="xs"
color="red" color="red"
loading={deleting} loading={deleting}
onClick={async () => { onClick={handleDeletePlugin()}
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await API.deletePlugin(deleteTarget.key);
if (resp?.success) {
setPlugins((prev) =>
prev.filter((p) => p.key !== deleteTarget.key)
);
notifications.show({
title: deleteTarget.name,
message: 'Plugin deleted',
color: 'green',
});
}
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
}}
> >
Delete Delete
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
</AppShell.Main>
{/* Confirmation modal */}
<Modal
opened={confirmOpen}
onClose={() => handleConfirm(false)}
title={confirmConfig.title}
centered
>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>
<Group justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => handleConfirm(false)}
>
Cancel
</Button>
<Button size="xs" onClick={() => handleConfirm(true)}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</AppShellMain>
); );
} }

File diff suppressed because it is too large Load diff

View file

@ -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 PROGRAM_HEIGHT = 90;
export const EXPANDED_PROGRAM_HEIGHT = 180; 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 = {}) { export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const map = new Map(); const map = new Map();
@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
return map; return map;
} }
export function mapProgramsByChannel(programs, channelIdByTvgId) { export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
if (!programs?.length || !channelIdByTvgId?.size) { if (!programs?.length || !channelIdByTvgId?.size) {
return new Map(); return new Map();
} }
const map = new Map(); const map = new Map();
const nowMs = getNowMs();
programs.forEach((program) => { programs.forEach((program) => {
const channelIds = channelIdByTvgId.get(String(program.tvg_id)); const channelIds = channelIdByTvgId.get(String(program.tvg_id));
if (!channelIds || channelIds.length === 0) { if (!channelIds || channelIds.length === 0) {
return; return;
} }
const startMs = program.startMs ?? dayjs(program.start_time).valueOf(); const startMs = program.startMs ?? convertToMs(program.start_time);
const endMs = program.endMs ?? dayjs(program.end_time).valueOf(); const endMs = program.endMs ?? convertToMs(program.end_time);
const programData = { const programData = {
...program, ...program,
startMs, startMs,
endMs, 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 // Add this program to all channels that share the same TVG ID
@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) {
}); });
return map; return map;
} };
export function computeRowHeights( export function computeRowHeights(
filteredChannels, filteredChannels,
@ -94,3 +120,282 @@ export function computeRowHeights(
return expanded ? expandedHeight : defaultHeight; 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);
}

View file

@ -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();
},
}));

View file

@ -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 };
};

View file

@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@ -12,6 +12,41 @@ dayjs.extend(relativeTime);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); 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 = () => { export const useUserTimeZone = () => {
const settings = useSettingsStore((s) => s.settings); const settings = useSettingsStore((s) => s.settings);
const [timeZone, setTimeZone] = useLocalStorage( const [timeZone, setTimeZone] = useLocalStorage(
@ -38,15 +73,15 @@ export const useTimeHelpers = () => {
(value) => { (value) => {
if (!value) return dayjs.invalid(); if (!value) return dayjs.invalid();
try { try {
return dayjs(value).tz(timeZone); return initializeTime(value).tz(timeZone);
} catch (error) { } catch (error) {
return dayjs(value); return initializeTime(value);
} }
}, },
[timeZone] [timeZone]
); );
const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]); const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
return { timeZone, toUserTime, userNow }; return { timeZone, toUserTime, userNow };
}; };
@ -68,7 +103,7 @@ export const useDateTimeFormat = () => {
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm'; const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
return [timeFormat, dateFormat] return [timeFormat, dateFormat];
}; };
export const toTimeString = (value) => { export const toTimeString = (value) => {
@ -78,7 +113,7 @@ export const toTimeString = (value) => {
if (parsed.isValid()) return parsed.format('HH:mm'); if (parsed.isValid()) return parsed.format('HH:mm');
return value; return value;
} }
const parsed = dayjs(value); const parsed = initializeTime(value);
return parsed.isValid() ? parsed.format('HH:mm') : '00:00'; return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
}; };
@ -86,4 +121,138 @@ export const parseDate = (value) => {
if (!value) return null; if (!value) return null;
const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true); const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
return parsed.isValid() ? parsed.toDate() : null; 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';
}
}; };

View file

@ -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,
};
};

View file

@ -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;
}, {});
};

View file

@ -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,
};
};

View file

@ -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'),
};
};

View file

@ -0,0 +1,5 @@
export const getSystemSettingsFormInitialValues = () => {
return {
'max-system-events': 100,
};
};

View file

@ -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,
});
}
};

View file

@ -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])/;

View file

@ -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);
}

View file

@ -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);
};

View file

@ -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;
}, {});
};