Merge pull request #803 from Dispatcharr/dev
Some checks are pending
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run

Version 0.16.0
This commit is contained in:
SergeantPanda 2026-01-03 19:15:10 -06:00 committed by GitHub
commit fdca1fd165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 6271 additions and 4807 deletions

View file

@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Advanced filtering for Channels table: Filter menu now allows toggling disabled channels visibility (when a profile is selected) and filtering to show only empty channels without streams (Closes #182)
- Network Access warning modal now displays the client's IP address for better transparency when network restrictions are being enforced - Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) (Closes #778)
- VLC streaming support - Thanks [@sethwv](https://github.com/sethwv)
- Added `cvlc` as an alternative streaming backend alongside FFmpeg and Streamlink
- Log parser refactoring: Introduced `LogParserFactory` and stream-specific parsers (`FFmpegLogParser`, `VLCLogParser`, `StreamlinkLogParser`) to enable codec and resolution detection from multiple streaming tools
- VLC log parsing for stream information: Detects video/audio codecs from TS demux output, supports both stream-copy and transcode modes with resolution/FPS extraction from transcode output
- Locked, read-only VLC stream profile configured for headless operation with intelligent audio/video codec detection
- VLC and required plugins installed in Docker environment with headless configuration
- ErrorBoundary component for handling frontend errors gracefully with generic error message - Thanks [@nick4810](https://github.com/nick4810)
### Changed
- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772)
- Region code options now intentionally include both `GB` (ISO 3166-1 standard) and `UK` (commonly used by EPG/XMLTV providers) to accommodate real-world EPG data variations. Many providers use `UK` in channel identifiers (e.g., `BBCOne.uk`) despite `GB` being the official ISO country code. Users should select the region code that matches their specific EPG provider's convention for optimal region-based EPG matching bonuses - Thanks [@bigpandaaaa](https://github.com/bigpandaaaa)
- Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database
- Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed)
- Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink)
- Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810)
- Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field)
- Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils)
- Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks
- Removed unused Dashboard and Home pages
- Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization
- Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic
- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements
- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs
- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810)
- Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files
- Moved larger nested components into their own files
- Moved business logic into corresponding utils/ files
- Extracted larger in-line component logic into its own function
- Each panel in Settings now uses its own form state with the parent component handling active state management
### Fixed
- Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets `force_dummy_epg` flag to prevent unwanted EPG assignment. (Fixes #788)
- VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770)
- VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736)
- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant)
- M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704)
- M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation)
- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect
- XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781)
- Docker container file permissions: Django management commands (`migrate`, `collectstatic`) now run as the non-root user to prevent root-owned `__pycache__` and static files from causing permission issues - Thanks [@sethwv](https://github.com/sethwv)
- Stream validation now continues with GET request if HEAD request fails due to connection issues - Thanks [@kvnnap](https://github.com/kvnnap) (Fixes #782)
- XtreamCodes M3U files now correctly set `x-tvg-url` and `url-tvg` headers to reference XC EPG URL (`xmltv.php`) instead of standard EPG endpoint when downloaded via XC API (Fixes #629)
## [0.15.1] - 2025-12-22
### Fixed

View file

@ -8,6 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Q
import os, json, requests, logging
from urllib.parse import unquote
from apps.accounts.permissions import (
@ -420,10 +421,36 @@ class ChannelViewSet(viewsets.ModelViewSet):
group_names = channel_group.split(",")
qs = qs.filter(channel_group__name__in=group_names)
if self.request.user.user_level < 10:
qs = qs.filter(user_level__lte=self.request.user.user_level)
filters = {}
q_filters = Q()
return qs
channel_profile_id = self.request.query_params.get("channel_profile_id")
show_disabled_param = self.request.query_params.get("show_disabled", None)
only_streamless = self.request.query_params.get("only_streamless", None)
if channel_profile_id:
try:
profile_id_int = int(channel_profile_id)
filters["channelprofilemembership__channel_profile_id"] = profile_id_int
if show_disabled_param is None:
filters["channelprofilemembership__enabled"] = True
except (ValueError, TypeError):
# Ignore invalid profile id values
pass
if only_streamless:
q_filters &= Q(streams__isnull=True)
if self.request.user.user_level < 10:
filters["user_level__lte"] = self.request.user.user_level
if filters:
qs = qs.filter(**filters)
if q_filters:
qs = qs.filter(q_filters)
return qs.distinct()
def get_serializer_context(self):
context = super().get_serializer_context()

View file

@ -174,16 +174,26 @@ def generate_m3u(request, profile_name=None, user=None):
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
# Build EPG URL with query parameters if needed
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
# Check if this is an XC API request (has username/password in GET params and user is authenticated)
xc_username = request.GET.get('username')
xc_password = request.GET.get('password')
# Optionally preserve certain query parameters
preserved_params = ['tvg_id_source', 'cachedlogos', 'days']
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
if query_params:
from urllib.parse import urlencode
epg_url = f"{epg_base_url}?{urlencode(query_params)}"
if user is not None and xc_username and xc_password:
# This is an XC API request - use XC-style EPG URL
base_url = build_absolute_uri_with_port(request, '')
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
else:
epg_url = epg_base_url
# Regular request - use standard EPG endpoint
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
# Optionally preserve certain query parameters
preserved_params = ['tvg_id_source', 'cachedlogos', 'days']
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
if query_params:
from urllib.parse import urlencode
epg_url = f"{epg_base_url}?{urlencode(query_params)}"
else:
epg_url = epg_base_url
# Add x-tvg-url and url-tvg attribute for EPG URL
m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n'
@ -247,12 +257,10 @@ def generate_m3u(request, profile_name=None, user=None):
stream_url = first_stream.url
else:
# Fall back to proxy URL if no direct URL available
base_url = request.build_absolute_uri('/')[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
else:
# Standard behavior - use proxy URL
base_url = request.build_absolute_uri('/')[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
m3u_content += extinf_line + stream_url + "\n"
@ -2258,7 +2266,7 @@ def xc_get_epg(request, user, short=False):
# Get the mapped integer for this specific channel
channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number))
limit = request.GET.get('limit', 4)
limit = int(request.GET.get('limit', 4))
if channel.epg_data:
# Check if this is a dummy EPG that generates on-demand
if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy':
@ -2932,19 +2940,16 @@ def get_host_and_port(request):
if xfh:
if ":" in xfh:
host, port = xfh.split(":", 1)
# Omit standard ports from URLs, or omit if port doesn't match standard for scheme
# (e.g., HTTPS but port is 9191 = behind external reverse proxy)
# Omit standard ports from URLs
if port == standard_port:
return host, None
# If port doesn't match standard and X-Forwarded-Proto is set, likely behind external RP
if request.META.get("HTTP_X_FORWARDED_PROTO"):
host = xfh.split(":")[0] # Strip port, will check for proper port below
else:
return host, port
# Non-standard port in X-Forwarded-Host - return it
# This handles reverse proxies on non-standard ports (e.g., https://example.com:8443)
return host, port
else:
host = xfh
# Check for X-Forwarded-Port header (if we didn't already find a valid port)
# Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host)
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
# Omit standard ports from URLs
@ -2962,22 +2967,28 @@ def get_host_and_port(request):
else:
host = raw_host
# 3. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
port = request.META.get("HTTP_X_FORWARDED_PORT")
if port:
# Omit standard ports from URLs
return host, None if port == standard_port else port
# 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
# If so, assume standard port for the scheme (don't trust SERVER_PORT in this case)
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
return host, None
# 4. Try SERVER_PORT from META (only if NOT behind reverse proxy)
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
port = request.META.get("SERVER_PORT")
if port:
# Omit standard ports from URLs
return host, None if port == standard_port else port
# 5. Dev fallback: guess port 5656
# 6. Dev fallback: guess port 5656
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
return host, "5656"
# 6. Final fallback: assume standard port for scheme (omit from URL)
# 7. Final fallback: assume standard port for scheme (omit from URL)
return host, None
def build_absolute_uri_with_port(request, path):

View file

@ -15,6 +15,7 @@ from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..url_utils import get_stream_info_for_switch
from core.utils import log_system_event
from .log_parsers import LogParserFactory
logger = logging.getLogger("ts_proxy")
@ -419,124 +420,51 @@ class ChannelService:
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""Parse FFmpeg stream info line and store in Redis metadata and database"""
"""
Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB.
Uses specialized parsers for each streaming tool.
"""
try:
if stream_type == "input":
# Example lines:
# Input #0, mpegts, from 'http://example.com/stream.ts':
# Input #0, hls, from 'http://example.com/stream.m3u8':
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# Extract input format (e.g., "mpegts", "hls", "flv", etc.)
input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line)
input_format = input_match.group(1).strip() if input_match else None
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
parsed_data.get('height'),
parsed_data.get('source_fps'),
parsed_data.get('pixel_format'),
parsed_data.get('video_bitrate'),
parsed_data.get('audio_codec'),
parsed_data.get('sample_rate'),
parsed_data.get('audio_channels'),
parsed_data.get('audio_bitrate'),
parsed_data.get('stream_type')
)
# Store in Redis if we have valid data
if input_format:
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format)
logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}")
elif stream_type == "video":
# Example line:
# Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn
# Extract video codec (e.g., "h264", "mpeg2video", etc.)
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line)
video_codec = codec_match.group(1) if codec_match else None
# Extract resolution (e.g., "1280x720") - be more specific to avoid hex values
# Look for resolution patterns that are realistic video dimensions
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
# Validate that these look like reasonable video dimensions
if 100 <= width <= 10000 and 100 <= height <= 10000:
resolution = f"{width}x{height}"
else:
width = height = resolution = None
else:
width = height = resolution = None
# Extract source FPS (e.g., "29.97 fps")
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line)
source_fps = float(fps_match.group(1)) if fps_match else None
# Extract pixel format (e.g., "yuv420p")
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line)
pixel_format = None
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
# Clean up pixel format (remove extra info in parentheses)
if '(' in pf:
pf = pf.split('(')[0].strip()
pixel_format = pf
# Extract bitrate if present (e.g., "2000 kb/s")
video_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
video_bitrate = float(bitrate_match.group(1))
# Store in Redis if we have valid data
if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=video_codec,
resolution=resolution,
source_fps=source_fps,
pixel_format=pixel_format,
video_bitrate=video_bitrate
)
logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, "
f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, "
f"Video Bitrate: {video_bitrate} kb/s")
elif stream_type == "audio":
# Example line:
# Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s
# Extract audio codec (e.g., "aac", "mp3", etc.)
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line)
audio_codec = codec_match.group(1) if codec_match else None
# Extract sample rate (e.g., "48000 Hz")
sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line)
sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None
# Extract channel layout (e.g., "stereo", "5.1", "mono")
# Look for common channel layouts
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE)
channels = channel_match.group(1) if channel_match else None
# Extract audio bitrate if present (e.g., "64 kb/s")
audio_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
audio_bitrate = float(bitrate_match.group(1))
# Store in Redis if we have valid data
if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
audio_codec=audio_codec,
sample_rate=sample_rate,
audio_channels=channels,
audio_bitrate=audio_bitrate
)
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=parsed_data.get('video_codec'),
resolution=parsed_data.get('resolution'),
source_fps=parsed_data.get('source_fps'),
pixel_format=parsed_data.get('pixel_format'),
video_bitrate=parsed_data.get('video_bitrate'),
audio_codec=parsed_data.get('audio_codec'),
sample_rate=parsed_data.get('sample_rate'),
audio_channels=parsed_data.get('audio_channels'),
audio_bitrate=parsed_data.get('audio_bitrate'),
stream_type=parsed_data.get('stream_type')
)
except Exception as e:
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
logger.debug(f"Error parsing {stream_type} stream info: {e}")
@staticmethod
def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None):

View file

@ -0,0 +1,410 @@
"""Log parsers for FFmpeg, Streamlink, and VLC output."""
import re
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class BaseLogParser(ABC):
"""Base class for log parsers"""
# Map of stream_type -> method_name that this parser handles
STREAM_TYPE_METHODS: Dict[str, str] = {}
@abstractmethod
def can_parse(self, line: str) -> Optional[str]:
"""
Check if this parser can handle the line.
Returns the stream_type if it can parse, None otherwise.
e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink'
"""
pass
@abstractmethod
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
class FFmpegLogParser(BaseLogParser):
"""Parser for FFmpeg log output"""
STREAM_TYPE_METHODS = {
'input': 'parse_input_format',
'video': 'parse_video_stream',
'audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is an FFmpeg line we can parse"""
lower = line.lower()
# Input format detection
if lower.startswith('input #'):
return 'input'
# Stream info (only during input phase, but we'll let stream_manager handle phase tracking)
if 'stream #' in lower:
if 'video:' in lower:
return 'video'
elif 'audio:' in lower:
return 'audio'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg input format (e.g., mpegts, hls)"""
try:
input_match = re.search(r'Input #\d+,\s*([^,]+)', line)
input_format = input_match.group(1).strip() if input_match else None
if input_format:
logger.debug(f"Input format info - Format: {input_format}")
return {'stream_type': input_format}
except Exception as e:
logger.debug(f"Error parsing FFmpeg input format: {e}")
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg video stream info"""
try:
result = {}
# Extract codec, resolution, fps, pixel format, bitrate
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['video_codec'] = codec_match.group(1)
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line)
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
if '(' in pf:
pf = pf.split('(')[0].strip()
result['pixel_format'] = pf
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['video_bitrate'] = float(bitrate_match.group(1))
if result:
logger.info(f"Video stream info - Codec: {result.get('video_codec')}, "
f"Resolution: {result.get('resolution')}, "
f"Source FPS: {result.get('source_fps')}, "
f"Pixel Format: {result.get('pixel_format')}, "
f"Video Bitrate: {result.get('video_bitrate')} kb/s")
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg audio stream info"""
try:
result = {}
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['audio_codec'] = codec_match.group(1)
sample_rate_match = re.search(r'(\d+)\s*Hz', line)
if sample_rate_match:
result['sample_rate'] = int(sample_rate_match.group(1))
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE)
if channel_match:
result['audio_channels'] = channel_match.group(1)
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['audio_bitrate'] = float(bitrate_match.group(1))
if result:
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg audio stream info: {e}")
return None
class VLCLogParser(BaseLogParser):
"""Parser for VLC log output"""
STREAM_TYPE_METHODS = {
'vlc_video': 'parse_video_stream',
'vlc_audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a VLC line we can parse"""
lower = line.lower()
# VLC TS demux codec detection
if 'ts demux debug' in lower and 'type=' in lower:
if 'video' in lower:
return 'vlc_video'
elif 'audio' in lower:
return 'vlc_audio'
# VLC decoder output
if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower):
if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower:
return 'vlc_audio'
else:
return 'vlc_video'
# VLC transcode output for resolution/FPS
if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)):
return 'vlc_video'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for video"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
video_codec_map = {
('avc', 'h.264', 'type=0x1b'): "h264",
('hevc', 'h.265', 'type=0x24'): "hevc",
('mpeg-2', 'type=0x02'): "mpeg2video",
('mpeg-4', 'type=0x10'): "mpeg4"
}
for patterns, codec in video_codec_map.items():
if any(p in lower for p in patterns):
result['video_codec'] = codec
break
# Extract FPS from transcode output: "source fps 30/1"
fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower)
if fps_fraction_match:
numerator = int(fps_fraction_match.group(1))
denominator = int(fps_fraction_match.group(2))
if denominator > 0:
result['source_fps'] = numerator / denominator
# Extract resolution from transcode output: "source 1280x720"
source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower)
if source_res_match:
width = int(source_res_match.group(1))
height = int(source_res_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
else:
# Fallback: generic resolution pattern
resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
# Fallback: try to extract FPS from generic format
if 'source_fps' not in result:
fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
return result if result else None
except Exception as e:
logger.debug(f"Error parsing VLC video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for audio"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
audio_codec_map = {
('type=0xf', 'adts'): "aac",
('type=0x03', 'type=0x04'): "mp3",
('type=0x06', 'type=0x81'): "ac3",
('type=0x0b', 'lpcm'): "pcm"
}
for patterns, codec in audio_codec_map.items():
if any(p in lower for p in patterns):
result['audio_codec'] = codec
break
# VLC decoder format: "AAC channels: 2 samplerate: 48000"
if 'channels:' in lower:
channels_match = re.search(r'channels:\s*(\d+)', lower)
if channels_match:
num_channels = int(channels_match.group(1))
# Convert number to name
channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'}
result['audio_channels'] = channel_names.get(num_channels, str(num_channels))
if 'samplerate:' in lower:
samplerate_match = re.search(r'samplerate:\s*(\d+)', lower)
if samplerate_match:
result['sample_rate'] = int(samplerate_match.group(1))
# Try to extract sample rate (Hz format)
sample_rate_match = re.search(r'(\d+)\s*hz', lower)
if sample_rate_match and 'sample_rate' not in result:
result['sample_rate'] = int(sample_rate_match.group(1))
# Try to extract channels (word format)
if 'audio_channels' not in result:
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower)
if channel_match:
result['audio_channels'] = channel_match.group(1)
return result if result else None
except Exception as e:
logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}")
return None
class StreamlinkLogParser(BaseLogParser):
"""Parser for Streamlink log output"""
STREAM_TYPE_METHODS = {
'streamlink': 'parse_video_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a Streamlink line we can parse"""
lower = line.lower()
if 'opening stream:' in lower or 'available streams:' in lower:
return 'streamlink'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse Streamlink quality/resolution"""
try:
quality_match = re.search(r'(\d+p|\d+x\d+)', line)
if quality_match:
quality = quality_match.group(1)
if 'x' in quality:
resolution = quality
width, height = map(int, quality.split('x'))
else:
resolutions = {
'2160p': ('3840x2160', 3840, 2160),
'1080p': ('1920x1080', 1920, 1080),
'720p': ('1280x720', 1280, 720),
'480p': ('854x480', 854, 480),
'360p': ('640x360', 640, 360)
}
resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080))
return {
'video_codec': 'h264',
'resolution': resolution,
'width': width,
'height': height,
'pixel_format': 'yuv420p'
}
except Exception as e:
logger.debug(f"Error parsing Streamlink video info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
return None
class LogParserFactory:
"""Factory to get the appropriate log parser"""
_parsers = {
'ffmpeg': FFmpegLogParser(),
'vlc': VLCLogParser(),
'streamlink': StreamlinkLogParser()
}
@classmethod
def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]:
"""Determine parser and method from stream_type"""
# Check each parser to see if it handles this stream_type
for parser in cls._parsers.values():
method_name = parser.STREAM_TYPE_METHODS.get(stream_type)
if method_name:
return (parser, method_name)
return None
@classmethod
def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]:
"""
Parse a log line based on stream type.
Returns parsed data or None if parsing fails.
"""
result = cls._get_parser_and_method(stream_type)
if not result:
return None
parser, method_name = result
method = getattr(parser, method_name, None)
if method:
return method(line)
return None
@classmethod
def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]:
"""
Automatically detect which parser can handle this line and parse it.
Returns (stream_type, parsed_data) or None if no parser can handle it.
"""
# Try each parser to see if it can handle this line
for parser in cls._parsers.values():
stream_type = parser.can_parse(line)
if stream_type:
# Parser can handle this line, now parse it
parsed_data = cls.parse(stream_type, line)
if parsed_data:
return (stream_type, parsed_data)
return None

View file

@ -107,6 +107,10 @@ class StreamManager:
# Add this flag for tracking transcoding process status
self.transcode_process_active = False
# Track stream command for efficient log parser routing
self.stream_command = None
self.parser_type = None # Will be set when transcode process starts
# Add tracking for data throughput
self.bytes_processed = 0
self.last_bytes_update = time.time()
@ -476,6 +480,21 @@ class StreamManager:
# Build and start transcode command
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
# Store stream command for efficient log parser routing
self.stream_command = stream_profile.command
# Map actual commands to parser types for direct routing
command_to_parser = {
'ffmpeg': 'ffmpeg',
'cvlc': 'vlc',
'vlc': 'vlc',
'streamlink': 'streamlink'
}
self.parser_type = command_to_parser.get(self.stream_command.lower())
if self.parser_type:
logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})")
else:
logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing")
# For UDP streams, remove any user_agent parameters from the command
if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP:
# Filter out any arguments that contain the user_agent value or related headers
@ -645,35 +664,51 @@ class StreamManager:
if content_lower.startswith('output #') or 'encoder' in content_lower:
self.ffmpeg_input_phase = False
# Only parse stream info if we're still in the input phase
if ("stream #" in content_lower and
("video:" in content_lower or "audio:" in content_lower) and
self.ffmpeg_input_phase):
# Route to appropriate parser based on known command type
from .services.log_parsers import LogParserFactory
from .services.channel_service import ChannelService
from .services.channel_service import ChannelService
if "video:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id)
elif "audio:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id)
parse_result = None
# If we know the parser type, use direct routing for efficiency
if self.parser_type:
# Get the appropriate parser and check what it can parse
parser = LogParserFactory._parsers.get(self.parser_type)
if parser:
stream_type = parser.can_parse(content)
if stream_type:
# Parser can handle this line, parse it directly
parsed_data = LogParserFactory.parse(stream_type, content)
if parsed_data:
parse_result = (stream_type, parsed_data)
else:
# Unknown command type - use auto-detection as fallback
parse_result = LogParserFactory.auto_parse(content)
if parse_result:
stream_type, parsed_data = parse_result
# For FFmpeg, only parse during input phase
if stream_type in ['video', 'audio', 'input']:
if self.ffmpeg_input_phase:
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
else:
# VLC and Streamlink can be parsed anytime
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
# Determine log level based on content
if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']):
logger.error(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.error(f"Stream process error for channel {self.channel_id}: {content}")
elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']):
logger.warning(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.warning(f"Stream process warning for channel {self.channel_id}: {content}")
elif content.startswith('frame=') or 'fps=' in content or 'speed=' in content:
# Stats lines - log at trace level to avoid spam
logger.trace(f"FFmpeg stats for channel {self.channel_id}: {content}")
logger.trace(f"Stream stats for channel {self.channel_id}: {content}")
elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']):
# Stream info - log at info level
logger.info(f"FFmpeg info for channel {self.channel_id}: {content}")
if content.startswith('Input #0'):
# If it's input 0, parse stream info
from .services.channel_service import ChannelService
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id)
logger.info(f"Stream info for channel {self.channel_id}: {content}")
else:
# Everything else at debug level
logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.debug(f"Stream process output for channel {self.channel_id}: {content}")
except Exception as e:
logger.error(f"Error logging stderr content for channel {self.channel_id}: {e}")

View file

@ -462,16 +462,21 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
session.headers.update(headers)
# Make HEAD request first as it's faster and doesn't download content
head_response = session.head(
url,
timeout=timeout,
allow_redirects=True
)
head_request_success = True
try:
head_response = session.head(
url,
timeout=timeout,
allow_redirects=True
)
except requests.exceptions.RequestException as e:
head_request_success = False
logger.warning(f"Request error (HEAD), assuming HEAD not supported: {str(e)}")
# If HEAD not supported, server will return 405 or other error
if 200 <= head_response.status_code < 300:
if head_request_success and (200 <= head_response.status_code < 300):
# HEAD request successful
return True, head_response.url, head_response.status_code, "Valid (HEAD request)"
return True, url, head_response.status_code, "Valid (HEAD request)"
# Try a GET request with stream=True to avoid downloading all content
get_response = session.get(
@ -484,7 +489,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
# IMPORTANT: Check status code first before checking content
if not (200 <= get_response.status_code < 300):
logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}")
return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}"
return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}"
# Only check content if status code is valid
try:
@ -538,7 +543,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
get_response.close()
# If we have content, consider it valid even with unrecognized content type
return is_valid, get_response.url, get_response.status_code, message
return is_valid, url, get_response.status_code, message
except requests.exceptions.Timeout:
return False, url, 0, "Timeout connecting to stream"

View file

@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type'
if '|' in value:
category_name, category_type = value.split('|', 1)
category_name, category_type = value.rsplit('|', 1)
return queryset.filter(
m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type
@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type'
if '|' in value:
category_name, category_type = value.split('|', 1)
category_name, category_type = value.rsplit('|', 1)
return queryset.filter(
m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type
@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if category:
if '|' in category:
cat_name, cat_type = category.split('|', 1)
cat_name, cat_type = category.rsplit('|', 1)
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series

View file

@ -1292,8 +1292,17 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
try:
episode_id = str(episode_data.get('id'))
episode_name = episode_data.get('title', 'Unknown Episode')
season_number = episode_data['_season_number']
episode_number = episode_data.get('episode_num', 0)
# Ensure season and episode numbers are integers (API may return strings)
try:
season_number = int(episode_data['_season_number'])
except (ValueError, TypeError) as e:
logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}")
season_number = 0
try:
episode_number = int(episode_data.get('episode_num', 0))
except (ValueError, TypeError) as e:
logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}")
episode_number = 0
info = episode_data.get('info', {})
# Extract episode metadata
@ -1324,7 +1333,7 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
# Check if we already have this episode pending creation (multiple streams for same episode)
if not episode and episode_key in episodes_pending_creation:
episode = episodes_pending_creation[episode_key]
logger.debug(f"Reusing pending episode for S{season_number:02d}E{episode_number:02d} (stream_id: {episode_id})")
logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})")
if episode:
# Update existing episode
@ -1432,6 +1441,21 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None)
if key in episode_pk_map:
relation.episode = episode_pk_map[key]
# Filter out relations with unsaved episodes (no PK)
# This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode
valid_relations_to_create = []
for relation in relations_to_create:
if relation.episode.pk is not None:
valid_relations_to_create.append(relation)
else:
season_num = relation.episode.season_number
episode_num = relation.episode.episode_number
logger.warning(
f"Skipping relation for episode S{season_num}E{episode_num} "
f"- episode not saved to database"
)
relations_to_create = valid_relations_to_create
# Update existing episodes
if episodes_to_update:
Episode.objects.bulk_update(episodes_to_update, [

View file

@ -142,8 +142,12 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
},
status=status.HTTP_200_OK,
)
return Response(in_network, status=status.HTTP_200_OK)
response_data = {
**in_network,
"client_ip": str(client_ip)
}
return Response(response_data, status=status.HTTP_200_OK)
return Response({}, status=status.HTTP_200_OK)

View file

@ -23,7 +23,7 @@
"model": "core.streamprofile",
"pk": 1,
"fields": {
"name": "ffmpeg",
"name": "FFmpeg",
"command": "ffmpeg",
"parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
"is_active": true,
@ -34,11 +34,22 @@
"model": "core.streamprofile",
"pk": 2,
"fields": {
"name": "streamlink",
"name": "Streamlink",
"command": "streamlink",
"parameters": "{streamUrl} best --stdout",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.streamprofile",
"pk": 3,
"fields": {
"name": "VLC",
"command": "cvlc",
"parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}",
"is_active": true,
"user_agent": "1"
}
}
]

View file

@ -0,0 +1,42 @@
# Generated migration to add VLC stream profile
from django.db import migrations
def add_vlc_profile(apps, schema_editor):
StreamProfile = apps.get_model("core", "StreamProfile")
UserAgent = apps.get_model("core", "UserAgent")
# Check if VLC profile already exists
if not StreamProfile.objects.filter(name="VLC").exists():
# Get the TiviMate user agent (should be pk=1)
try:
tivimate_ua = UserAgent.objects.get(pk=1)
except UserAgent.DoesNotExist:
# Fallback: get first available user agent
tivimate_ua = UserAgent.objects.first()
if not tivimate_ua:
# No user agents exist, skip creating profile
return
StreamProfile.objects.create(
name="VLC",
command="cvlc",
parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}",
is_active=True,
user_agent=tivimate_ua,
locked=True, # Make it read-only like ffmpeg/streamlink
)
def remove_vlc_profile(apps, schema_editor):
StreamProfile = apps.get_model("core", "StreamProfile")
StreamProfile.objects.filter(name="VLC").delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0018_alter_systemevent_event_type'),
]
operations = [
migrations.RunPython(add_vlc_profile, remove_vlc_profile),
]

View file

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

View file

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

View file

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

View file

@ -36,7 +36,7 @@
"model": "core.streamprofile",
"pk": 1,
"fields": {
"profile_name": "ffmpeg",
"profile_name": "FFmpeg",
"command": "ffmpeg",
"parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1",
"is_active": true,
@ -46,13 +46,23 @@
{
"model": "core.streamprofile",
"fields": {
"profile_name": "streamlink",
"profile_name": "Streamlink",
"command": "streamlink",
"parameters": "{streamUrl} best --stdout",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.streamprofile",
"fields": {
"profile_name": "VLC",
"command": "cvlc",
"parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}",
"is_active": true,
"user_agent": "1"
}
},
{
"model": "core.coresettings",
"fields": {

View file

@ -19,7 +19,6 @@ import Users from './pages/Users';
import LogosPage from './pages/Logos';
import VODsPage from './pages/VODs';
import useAuthStore from './store/auth';
import useLogosStore from './store/logos';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
import { Box, AppShell, MantineProvider } from '@mantine/core';
@ -40,8 +39,6 @@ const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const [backgroundLoadingStarted, setBackgroundLoadingStarted] =
useState(false);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
const logout = useAuthStore((s) => s.logout);
@ -81,11 +78,7 @@ const App = () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
// Start background logo loading after app is fully initialized (only once)
if (!backgroundLoadingStarted) {
setBackgroundLoadingStarted(true);
useLogosStore.getState().startBackgroundLoading();
}
// Logos are now loaded at the end of initData, no need for background loading
} else {
await logout();
}
@ -96,7 +89,7 @@ const App = () => {
};
checkAuth();
}, [initializeAuth, initData, logout, backgroundLoadingStarted]);
}, [initializeAuth, initData, logout]);
return (
<MantineProvider

View file

@ -0,0 +1,18 @@
import React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>Something went wrong</div>;
}
return this.props.children;
}
}
export default ErrorBoundary;

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

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Skeleton } from '@mantine/core';
import useLogosStore from '../store/logos';
import logo from '../images/logo.png'; // Default logo
@ -16,15 +16,16 @@ const LazyLogo = ({
}) => {
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const fetchAttempted = useRef(new Set()); // Track which IDs we've already tried to fetch
const fetchAttempted = useRef(new Set());
const isMountedRef = useRef(true);
const logos = useLogosStore((s) => s.logos);
const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds);
const allowLogoRendering = useLogosStore((s) => s.allowLogoRendering);
// Determine the logo source
const logoData = logoId && logos[logoId];
const logoSrc = logoData?.cache_url || fallbackSrc; // Only use cache URL if we have logo data
const logoSrc = logoData?.cache_url || fallbackSrc;
// Cleanup on unmount
useEffect(() => {
@ -34,6 +35,9 @@ const LazyLogo = ({
}, []);
useEffect(() => {
// Don't start fetching until logo rendering is allowed
if (!allowLogoRendering) return;
// If we have a logoId but no logo data, add it to the batch request queue
if (
logoId &&
@ -44,7 +48,7 @@ const LazyLogo = ({
isMountedRef.current
) {
setIsLoading(true);
fetchAttempted.current.add(logoId); // Mark this ID as attempted
fetchAttempted.current.add(logoId);
logoRequestQueue.add(logoId);
// Clear existing timer and set new one to batch requests
@ -82,7 +86,7 @@ const LazyLogo = ({
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logoId, fetchLogosByIds, logoData]); // Include logoData to detect when it becomes available
}, [logoId, fetchLogosByIds, logoData, allowLogoRendering]);
// Reset error state when logoId changes
useEffect(() => {
@ -91,8 +95,10 @@ const LazyLogo = ({
}
}, [logoId]);
// Show skeleton while loading
if (isLoading && !logoData) {
// Show skeleton if:
// 1. Logo rendering is not allowed yet, OR
// 2. We don't have logo data yet (regardless of loading state)
if (logoId && (!allowLogoRendering || !logoData)) {
return (
<Skeleton
height={style.maxHeight || 18}

View file

@ -0,0 +1,26 @@
import { Text, } from '@mantine/core';
// Short preview that triggers the details modal when clicked
const RecordingSynopsis = ({ description, onOpen }) => {
const truncated = description?.length > 140;
const preview = truncated
? `${description.slice(0, 140).trim()}...`
: description;
if (!description) return null;
return (
<Text
size="xs"
c="dimmed"
lineClamp={2}
title={description}
onClick={() => onOpen?.()}
style={{ cursor: 'pointer' }}
>
{preview}
</Text>
);
};
export default RecordingSynopsis;

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

@ -0,0 +1,422 @@
import useChannelsStore from '../../store/channels.jsx';
import useSettingsStore from '../../store/settings.jsx';
import useVideoStore from '../../store/useVideoStore.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Center,
Flex,
Group,
Image,
Modal,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, SquareX } from 'lucide-react';
import RecordingSynopsis from '../RecordingSynopsis';
import {
deleteRecordingById,
deleteSeriesAndRule,
getPosterUrl,
getRecordingUrl,
getSeasonLabel,
getSeriesInfo,
getShowVideoUrl,
removeRecording,
runComSkip,
} from './../../utils/cards/RecordingCardUtils.js';
const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const channels = useChannelsStore((s) => s.channels);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const { toUserTime, userNow } = useTimeHelpers();
const [timeformat, dateformat] = useDateTimeFormat();
const channel = channels?.[recording.channel];
const customProps = recording.custom_properties || {};
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
const subTitle = program.sub_title || '';
const description = program.description || customProps.description || '';
const isRecurringRule = customProps?.rule?.type === 'recurring';
// Poster or channel logo
const posterUrl = getPosterUrl(
customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode);
const start = toUserTime(recording.start_time);
const end = toUserTime(recording.end_time);
const now = userNow();
const status = customProps.status;
const isTimeActive = now.isAfter(start) && now.isBefore(end);
const isInterrupted = status === 'interrupted';
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
const isUpcoming = now.isBefore(start);
const isSeriesGroup = Boolean(
recording._group_count && recording._group_count > 1
);
// Season/Episode display if present
const season = customProps.season ?? program?.custom_properties?.season;
const episode = customProps.episode ?? program?.custom_properties?.episode;
const onscreen =
customProps.onscreen_episode ??
program?.custom_properties?.onscreen_episode;
const seLabel = getSeasonLabel(season, episode, onscreen);
const handleWatchLive = () => {
if (!channel) return;
showVideo(getShowVideoUrl(channel, env_mode), 'live');
};
const handleWatchRecording = () => {
// Only enable if backend provides a playable file URL in custom properties
const fileUrl = getRecordingUrl(customProps, env_mode);
if (!fileUrl) return;
showVideo(fileUrl, 'vod', {
name: recordingName,
logo: { url: posterUrl },
});
};
const handleRunComskip = async (e) => {
e?.stopPropagation?.();
try {
await runComSkip(recording);
notifications.show({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
color: 'blue.5',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to queue comskip for recording', error);
}
};
// Cancel handling for series groups
const [cancelOpen, setCancelOpen] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const handleCancelClick = (e) => {
e.stopPropagation();
if (isRecurringRule) {
onOpenRecurring?.(recording, true);
return;
}
if (isSeriesGroup) {
setCancelOpen(true);
} else {
removeRecording(recording.id);
}
};
const seriesInfo = getSeriesInfo(customProps);
const removeUpcomingOnly = async () => {
try {
setBusy(true);
await deleteRecordingById(recording.id);
} finally {
setBusy(false);
setCancelOpen(false);
try {
await fetchRecordings();
} catch (error) {
console.error('Failed to refresh recordings', error);
}
}
};
const removeSeriesAndRule = async () => {
try {
setBusy(true);
await deleteSeriesAndRule(seriesInfo);
} finally {
setBusy(false);
setCancelOpen(false);
try {
await fetchRecordings();
} catch (error) {
console.error(
'Failed to refresh recordings after series removal',
error
);
}
}
};
const handleOnMainCardClick = () => {
if (isRecurringRule) {
onOpenRecurring?.(recording, false);
} else {
onOpenDetails?.(recording);
}
}
const WatchLive = () => {
return <Button
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
handleWatchLive();
}}
>
Watch Live
</Button>;
}
const WatchRecording = () => {
return <Tooltip
label={
customProps.file_url || customProps.output_file_url
? 'Watch recording'
: 'Recording playback not available yet'
}
>
<Button
size="xs"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleWatchRecording();
}}
disabled={
customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)
}
>
Watch
</Button>
</Tooltip>;
}
const MainCard = (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
color: '#fff',
backgroundColor: isInterrupted ? '#2b1f20' : '#27272A',
borderColor: isInterrupted ? '#a33' : undefined,
height: '100%',
cursor: 'pointer',
}}
onClick={handleOnMainCardClick}
>
<Flex justify="space-between" align="center" pb={8}>
<Group gap={8} flex={1} miw={0}>
<Badge
color={
isInterrupted
? 'red.7'
: isInProgress
? 'red.6'
: isUpcoming
? 'yellow.6'
: 'gray.6'
}
>
{isInterrupted
? 'Interrupted'
: isInProgress
? 'Recording'
: isUpcoming
? 'Scheduled'
: 'Completed'}
</Badge>
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
<Stack gap={2} flex={1} miw={0}>
<Group gap={8} wrap="nowrap">
<Text fw={600} lineClamp={1} title={recordingName}>
{recordingName}
</Text>
{isSeriesGroup && (
<Badge color="teal" variant="filled">
Series
</Badge>
)}
{isRecurringRule && (
<Badge color="blue" variant="light">
Recurring
</Badge>
)}
{seLabel && !isSeriesGroup && (
<Badge color="gray" variant="light">
{seLabel}
</Badge>
)}
</Group>
</Stack>
</Group>
<Center>
<Tooltip label={isUpcoming || isInProgress ? 'Cancel' : 'Delete'}>
<ActionIcon
variant="transparent"
color="red.9"
onMouseDown={(e) => e.stopPropagation()}
onClick={handleCancelClick}
>
<SquareX size="20" />
</ActionIcon>
</Tooltip>
</Center>
</Flex>
<Flex gap="sm" align="center">
<Image
src={posterUrl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={6} flex={1}>
{!isSeriesGroup && subTitle && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Episode
</Text>
<Text size="sm" fw={700} title={subTitle}>
{subTitle}
</Text>
</Group>
)}
<Group justify="space-between">
<Text size="sm" c="dimmed">
Channel
</Text>
<Text size="sm">
{channel ? `${channel.channel_number}${channel.name}` : '—'}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{isSeriesGroup ? 'Next recording' : 'Time'}
</Text>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
</Text>
</Group>
{!isSeriesGroup && description && (
<RecordingSynopsis
description={description}
onOpen={() => onOpenDetails?.(recording)}
/>
)}
{isInterrupted && customProps.interrupted_reason && (
<Text size="xs" c="red.4">
{customProps.interrupted_reason}
</Text>
)}
<Group justify="flex-end" gap="xs" pt={4}>
{isInProgress && <WatchLive />}
{!isUpcoming && <WatchRecording />}
{!isUpcoming &&
customProps?.status === 'completed' &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
size="xs"
variant="light"
color="teal"
onClick={handleRunComskip}
>
Remove commercials
</Button>
)}
</Group>
</Stack>
</Flex>
{/* If this card is a grouped upcoming series, show count */}
{recording._group_count > 1 && (
<Text
size="xs"
c="dimmed"
style={{ position: 'absolute', bottom: 6, right: 12 }}
>
Next of {recording._group_count}
</Text>
)}
</Card>
);
if (!isSeriesGroup) return MainCard;
// Stacked look for series groups: render two shadow layers behind the main card
return (
<Box style={{ position: 'relative' }}>
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title="Cancel Series"
centered
size="md"
zIndex={9999}
>
<Stack gap="sm">
<Text>This is a series rule. What would you like to cancel?</Text>
<Group justify="flex-end">
<Button
variant="default"
loading={busy}
onClick={removeUpcomingOnly}
>
Only this upcoming
</Button>
<Button color="red" loading={busy} onClick={removeSeriesAndRule}>
Entire series + rule
</Button>
</Group>
</Stack>
</Modal>
<Box
style={{
position: 'absolute',
inset: 0,
transform: 'translate(10px, 10px) rotate(-1deg)',
borderRadius: 12,
backgroundColor: '#1f1f23',
border: '1px solid #2f2f34',
boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
<Box
style={{
position: 'absolute',
inset: 0,
transform: 'translate(5px, 5px) rotate(1deg)',
borderRadius: 12,
backgroundColor: '#232327',
border: '1px solid #333',
boxShadow: '0 4px 12px rgba(0,0,0,0.30)',
pointerEvents: 'none',
zIndex: 1,
}}
/>
<Box style={{ position: 'relative', zIndex: 2 }}>{MainCard}</Box>
</Box>
);
};
export default RecordingCard;

View file

@ -96,28 +96,30 @@ const LiveGroupFilter = ({
}
setGroupStates(
playlist.channel_groups.map((group) => {
// Parse custom_properties if present
let customProps = {};
if (group.custom_properties) {
try {
customProps =
typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch {
customProps = {};
playlist.channel_groups
.filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist
.map((group) => {
// Parse custom_properties if present
let customProps = {};
if (group.custom_properties) {
try {
customProps =
typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch {
customProps = {};
}
}
}
return {
...group,
name: channelGroups[group.channel_group].name,
auto_channel_sync: group.auto_channel_sync || false,
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
custom_properties: customProps,
original_enabled: group.enabled,
};
})
return {
...group,
name: channelGroups[group.channel_group].name,
auto_channel_sync: group.auto_channel_sync || false,
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
custom_properties: customProps,
original_enabled: group.enabled,
};
})
);
}, [playlist, channelGroups]);
@ -367,7 +369,8 @@ const LiveGroupFilter = ({
if (
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
) {
selectedValues.push('force_epg');
}
@ -430,23 +433,20 @@ const LiveGroupFilter = ({
// Handle 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 (
newCustomProps.force_dummy_epg &&
newCustomProps.custom_epg_id === undefined
newCustomProps.custom_epg_id ===
undefined &&
!newCustomProps.force_dummy_epg
) {
// Migrate: force_dummy_epg=true becomes custom_epg_id=null
newCustomProps.custom_epg_id = null;
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;
// Default to "No EPG (Disabled)"
newCustomProps.force_dummy_epg = true;
}
} else {
// Only remove custom_epg_id when deselected
// Remove all EPG settings when deselected
delete newCustomProps.custom_epg_id;
delete newCustomProps.force_dummy_epg;
delete newCustomProps.force_epg_selected;
}
// Handle group_override
@ -1122,7 +1122,8 @@ const LiveGroupFilter = ({
{/* Show EPG selector when force_epg is selected */}
{(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
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
@ -1131,44 +1132,90 @@ const LiveGroupFilter = ({
label="EPG Source"
placeholder="No EPG (Disabled)"
value={(() => {
// Handle migration from force_dummy_epg
// Show custom EPG if set
if (
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 ===
null
? '0'
: 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 group.custom_properties.custom_epg_id.toString();
}
// Show "No EPG" if force_dummy_epg is set
if (group.custom_properties?.force_dummy_epg) {
return '0';
}
return '0';
// Otherwise show empty/placeholder
return null;
})()}
onChange={(value) => {
// Convert back: '0' means no EPG (null)
const newValue =
value === '0' ? null : parseInt(value);
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group === group.channel_group
) {
return {
...state,
custom_properties: {
if (value === '0') {
// "No EPG (Disabled)" selected - use force_dummy_epg
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
const newProps = {
...state.custom_properties,
custom_epg_id: newValue,
},
};
}
return state;
})
);
};
delete newProps.custom_epg_id;
delete newProps.force_epg_selected;
newProps.force_dummy_epg = true;
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={[
{ 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,362 @@
import useChannelsStore from '../../store/channels.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
import React from 'react';
import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
import {
deleteRecordingById,
getPosterUrl,
getRecordingUrl,
getSeasonLabel,
getShowVideoUrl,
runComSkip,
} from '../../utils/cards/RecordingCardUtils.js';
import {
getRating,
getStatRows,
getUpcomingEpisodes,
} from '../../utils/forms/RecordingDetailsModalUtils.js';
const RecordingDetailsModal = ({
opened,
onClose,
recording,
channel,
posterUrl,
onWatchLive,
onWatchRecording,
env_mode,
onEdit,
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
const channelMap = useChannelsStore((s) => s.channels);
const { toUserTime, userNow } = useTimeHelpers();
const [childOpen, setChildOpen] = React.useState(false);
const [childRec, setChildRec] = React.useState(null);
const [timeformat, dateformat] = useDateTimeFormat();
const safeRecording = recording || {};
const customProps = safeRecording.custom_properties || {};
const program = customProps.program || {};
const recordingName = program.title || 'Custom Recording';
const description = program.description || customProps.description || '';
const start = toUserTime(safeRecording.start_time);
const end = toUserTime(safeRecording.end_time);
const stats = customProps.stream_info || {};
const statRows = getStatRows(stats);
// Rating (if available)
const rating = getRating(customProps, program);
const ratingSystem = customProps.rating_system || 'MPAA';
const fileUrl = customProps.file_url || customProps.output_file_url;
const canWatchRecording =
(customProps.status === 'completed' ||
customProps.status === 'interrupted') &&
Boolean(fileUrl);
const isSeriesGroup = Boolean(
safeRecording._group_count && safeRecording._group_count > 1
);
const upcomingEpisodes = React.useMemo(() => {
return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
}, [
allRecordings,
isSeriesGroup,
program.tvg_id,
program.title,
toUserTime,
userNow,
]);
const handleOnWatchLive = () => {
const rec = childRec;
const now = userNow();
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
if (!channelMap[rec.channel]) return;
useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
}
}
const handleOnWatchRecording = () => {
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode)
if (!fileUrl) return;
useVideoStore.getState().showVideo(fileUrl, 'vod', {
name:
childRec.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
childRec.custom_properties?.poster_logo_id,
undefined,
channelMap[childRec.channel]?.logo?.cache_url
)
},
});
}
const handleRunComskip = async (e) => {
e.stopPropagation?.();
try {
await runComSkip(recording)
notifications.show({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
color: 'blue.5',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to run comskip', error);
}
}
if (!recording) return null;
const EpisodeRow = ({ rec }) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = toUserTime(rec.start_time);
const end = toUserTime(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, posterUrl);
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.error('Failed to refresh recordings after delete', error);
}
};
const handleOnMainCardClick = () => {
setChildRec(rec);
setChildOpen(true);
}
return (
<Card
withBorder
radius="md"
padding="sm"
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
onClick={handleOnMainCardClick}
>
<Flex gap="sm" align="center">
<Image
src={purl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={pr.title || recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
<Text
fw={600}
size="sm"
lineClamp={1}
title={pr.sub_title || pr.title}
>
{pr.sub_title || pr.title}
</Text>
{se && (
<Badge color="gray" variant="light">
{se}
</Badge>
)}
</Group>
<Text size="xs">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>
Remove
</Button>
</Group>
</Flex>
</Card>
);
};
const WatchLive = () => {
return <Button
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation?.();
onWatchLive();
}}
>
Watch Live
</Button>;
}
const WatchRecording = () => {
return <Button
size="xs"
variant="default"
onClick={(e) => {
e.stopPropagation?.();
onWatchRecording();
}}
disabled={!canWatchRecording}
>
Watch
</Button>;
}
const Edit = () => {
return <Button
size="xs"
variant="light"
color="blue"
onClick={(e) => {
e.stopPropagation?.();
onEdit(recording);
}}
>
Edit
</Button>;
}
const Series = () => {
return <Stack gap={10}>
{upcomingEpisodes.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming episodes found
</Text>
)}
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
))}
{childOpen && childRec && (
<RecordingDetailsModal
opened={childOpen}
onClose={() => setChildOpen(false)}
recording={childRec}
channel={channelMap[childRec.channel]}
posterUrl={getPosterUrl(
childRec.custom_properties?.poster_logo_id,
childRec.custom_properties,
channelMap[childRec.channel]?.logo?.cache_url
)}
env_mode={env_mode}
onWatchLive={handleOnWatchLive}
onWatchRecording={handleOnWatchRecording}
/>
)}
</Stack>;
}
const Movie = () => {
return <Flex gap="lg" align="flex-start">
<Image
src={posterUrl}
w={180}
h={240}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={8} style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">
{channel ? `${channel.channel_number}${channel.name}` : '—'}
</Text>
<Group gap={8}>
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{customProps.status === 'completed' &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
size="xs"
variant="light"
color="teal"
onClick={handleRunComskip}
>
Remove commercials
</Button>
)}
</Group>
</Group>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
</Text>
{rating && (
<Group gap={8}>
<Badge color="yellow" title={ratingSystem}>
{rating}
</Badge>
</Group>
)}
{description && (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{description}
</Text>
)}
{statRows.length > 0 && (
<Stack gap={4} pt={6}>
<Text fw={600} size="sm">
Stream Stats
</Text>
{statRows.map(([k, v]) => (
<Group key={k} justify="space-between">
<Text size="xs" c="dimmed">
{k}
</Text>
<Text size="xs">{v}</Text>
</Group>
))}
</Stack>
)}
</Stack>
</Flex>;
}
return (
<Modal
opened={opened}
onClose={onClose}
title={
isSeriesGroup
? `Series: ${recordingName}`
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`
}
size="lg"
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' },
}}
>
{isSeriesGroup ? <Series /> : <Movie />}
</Modal>
);
};
export default RecordingDetailsModal;

View file

@ -0,0 +1,381 @@
import useChannelsStore from '../../store/channels.jsx';
import {
parseDate,
RECURRING_DAY_OPTIONS,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from '@mantine/form';
import dayjs from 'dayjs';
import { notifications } from '@mantine/notifications';
import { Badge, Button, Card, Group, Modal, MultiSelect, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
deleteRecurringRuleById,
getChannelOptions,
getUpcomingOccurrences,
updateRecurringRule,
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
const channels = useChannelsStore((s) => s.channels);
const recurringRules = useChannelsStore((s) => s.recurringRules);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const recordings = useChannelsStore((s) => s.recordings);
const { toUserTime, userNow } = useTimeHelpers();
const [timeformat, dateformat] = useDateTimeFormat();
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [busyOccurrence, setBusyOccurrence] = useState(null);
const rule = recurringRules.find((r) => r.id === ruleId);
const channelOptions = useMemo(() => {
return getChannelOptions(channels);
}, [channels]);
const form = useForm({
mode: 'controlled',
initialValues: {
channel_id: '',
days_of_week: [],
rule_name: '',
start_time: dayjs().startOf('hour').format('HH:mm'),
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
start_date: dayjs().toDate(),
end_date: dayjs().toDate(),
enabled: true,
},
validate: {
channel_id: (value) => (value ? null : 'Select a channel'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
end_time: (value, values) => {
if (!value) return 'Select an end time';
const startValue = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (
startValue.isValid() &&
endValue.isValid() &&
endValue.diff(startValue, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
},
end_date: (value, values) => {
const endDate = dayjs(value);
const startDate = dayjs(values.start_date);
if (!value) return 'Select an end date';
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
return 'End date cannot be before start date';
}
return null;
},
},
});
useEffect(() => {
if (opened && rule) {
form.setValues({
channel_id: `${rule.channel}`,
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
rule_name: rule.name || '',
start_time: toTimeString(rule.start_time),
end_time: toTimeString(rule.end_time),
start_date: parseDate(rule.start_date) || dayjs().toDate(),
end_date: parseDate(rule.end_date),
enabled: Boolean(rule.enabled),
});
} else {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, ruleId, rule]);
const upcomingOccurrences = useMemo(() => {
return getUpcomingOccurrences(recordings, userNow, ruleId, toUserTime);
}, [recordings, ruleId, toUserTime, userNow]);
const handleSave = async (values) => {
if (!rule) return;
setSaving(true);
try {
await updateRecurringRule(ruleId, values);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
notifications.show({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
color: 'green',
autoClose: 2500,
});
onClose();
} catch (error) {
console.error('Failed to update recurring rule', error);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!rule) return;
setDeleting(true);
try {
await deleteRecurringRuleById(ruleId);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
notifications.show({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
color: 'red',
autoClose: 2500,
});
onClose();
} catch (error) {
console.error('Failed to delete recurring rule', error);
} finally {
setDeleting(false);
}
};
const handleToggleEnabled = async (checked) => {
if (!rule) return;
setSaving(true);
try {
await updateRecurringRuleEnabled(ruleId, checked);
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
notifications.show({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
? 'Future occurrences will resume'
: 'Upcoming occurrences were removed',
color: checked ? 'green' : 'yellow',
autoClose: 2500,
});
} catch (error) {
console.error('Failed to toggle recurring rule', error);
form.setFieldValue('enabled', !checked);
} finally {
setSaving(false);
}
};
const handleCancelOccurrence = async (occurrence) => {
setBusyOccurrence(occurrence.id);
try {
await deleteRecordingById(occurrence.id);
await fetchRecordings();
notifications.show({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
color: 'yellow',
autoClose: 2000,
});
} catch (error) {
console.error('Failed to cancel occurrence', error);
} finally {
setBusyOccurrence(null);
}
};
if (!rule) {
return (
<Modal opened={opened} onClose={onClose} title="Recurring Rule" centered>
<Text size="sm">Recurring rule not found.</Text>
</Modal>
);
}
const handleEnableChange = (event) => {
form.setFieldValue('enabled', event.currentTarget.checked);
handleToggleEnabled(event.currentTarget.checked);
}
const handleStartDateChange = (value) => {
form.setFieldValue('start_date', value || dayjs().toDate());
}
const handleEndDateChange = (value) => {
form.setFieldValue('end_date', value);
}
const handleStartTimeChange = (value) => {
form.setFieldValue('start_time', toTimeString(value));
}
const handleEndTimeChange = (value) => {
form.setFieldValue('end_time', toTimeString(value));
}
const UpcomingList = () => {
return <Stack gap="xs">
{upcomingOccurrences.map((occ) => {
const occStart = toUserTime(occ.start_time);
const occEnd = toUserTime(occ.end_time);
return (
<Card
key={`occ-${occ.id}`}
withBorder
padding="sm"
radius="md"
>
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button
size="xs"
variant="subtle"
onClick={() => {
onClose();
onEditOccurrence?.(occ);
}}
>
Edit
</Button>
<Button
size="xs"
color="red"
variant="light"
loading={busyOccurrence === occ.id}
onClick={() => handleCancelOccurrence(occ)}
>
Cancel
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>;
}
return (
<Modal
opened={opened}
onClose={onClose}
title={rule.name || 'Recurring Rule'}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>
{channels?.[rule.channel]?.name || `Channel ${rule.channel}`}
</Text>
<Switch
size="sm"
checked={form.values.enabled}
onChange={handleEnableChange}
label={form.values.enabled ? 'Enabled' : 'Paused'}
disabled={saving}
/>
</Group>
<form onSubmit={form.onSubmit(handleSave)}>
<Stack gap="md">
<Select
{...form.getInputProps('channel_id')}
label="Channel"
data={channelOptions}
searchable
/>
<TextInput
{...form.getInputProps('rule_name')}
label="Rule name"
placeholder="Morning News, Football Sundays, ..."
/>
<MultiSelect
{...form.getInputProps('days_of_week')}
label="Every"
data={RECURRING_DAY_OPTIONS.map((opt) => ({
value: String(opt.value),
label: opt.label,
}))}
searchable
clearable
/>
<Group grow>
<DatePickerInput
label="Start date"
value={form.values.start_date}
onChange={handleStartDateChange}
valueFormat="MMM D, YYYY"
/>
<DatePickerInput
label="End date"
value={form.values.end_date}
onChange={handleEndDateChange}
valueFormat="MMM D, YYYY"
minDate={form.values.start_date || undefined}
/>
</Group>
<Group grow>
<TimeInput
label="Start time"
value={form.values.start_time}
onChange={handleStartTimeChange}
withSeconds={false}
format="12"
amLabel="AM"
pmLabel="PM"
/>
<TimeInput
label="End time"
value={form.values.end_time}
onChange={handleEndTimeChange}
withSeconds={false}
format="12"
amLabel="AM"
pmLabel="PM"
/>
</Group>
<Group justify="space-between">
<Button type="submit" loading={saving}>
Save changes
</Button>
<Button
color="red"
variant="light"
loading={deleting}
onClick={handleDelete}
>
Delete rule
</Button>
</Group>
</Stack>
</form>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
Upcoming occurrences
</Text>
<Badge color="blue.6">{upcomingOccurrences.length}</Badge>
</Group>
{upcomingOccurrences.length === 0 ? (
<Text size="sm" c="dimmed">
No future airings currently scheduled.
</Text>
) : <UpcomingList />}
</Stack>
</Stack>
</Modal>
);
};
export default RecurringRuleModal;

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

@ -1,6 +1,11 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from 'react';
import useChannelsStore from '../../store/channels';
import useLogosStore from '../../store/logos';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import ChannelForm from '../forms/Channel';
@ -219,7 +224,7 @@ const ChannelRowActions = React.memo(
}
);
const ChannelsTable = ({}) => {
const ChannelsTable = ({ onReady }) => {
// EPG data lookup
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
@ -229,6 +234,7 @@ const ChannelsTable = ({}) => {
const canDeleteChannelGroup = useChannelsStore(
(s) => s.canDeleteChannelGroup
);
const hasSignaledReady = useRef(false);
/**
* STORES
@ -254,7 +260,6 @@ const ChannelsTable = ({}) => {
const channels = useChannelsStore((s) => s.channels);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const logos = useLogosStore((s) => s.logos);
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
});
@ -289,6 +294,9 @@ const ChannelsTable = ({}) => {
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
);
const [showDisabled, setShowDisabled] = useState(true);
const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] =
useState(false);
const [paginationString, setPaginationString] = useState('');
const [filters, setFilters] = useState({
@ -307,6 +315,8 @@ const ChannelsTable = ({}) => {
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [channelToDelete, setChannelToDelete] = useState(null);
const hasFetchedData = useRef(false);
// Column sizing state for resizable columns
// Store in localStorage but with empty object as default
const [columnSizing, setColumnSizing] = useLocalStorage(
@ -361,14 +371,30 @@ const ChannelsTable = ({}) => {
});
});
const channelsTableLength =
Object.keys(data).length > 0 || hasFetchedData.current
? Object.keys(data).length
: undefined;
/**
* Functions
*/
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
params.append('include_streams', 'true');
if (selectedProfileId !== '0') {
params.append('channel_profile_id', selectedProfileId);
}
if (showDisabled === true) {
params.append('show_disabled', true);
}
if (showOnlyStreamlessChannels === true) {
params.append('only_streamless', true);
}
// Apply sorting
if (sorting.length > 0) {
@ -397,11 +423,29 @@ const ChannelsTable = ({}) => {
await API.getAllChannelIds(params),
]);
setIsLoading(false);
hasFetchedData.current = true;
setTablePrefs({
pageSize: pagination.pageSize,
});
setAllRowIds(ids);
}, [pagination, sorting, debouncedFilters]);
// Signal ready after first successful data fetch
// EPG data is already loaded in initData before this component mounts
if (!hasSignaledReady.current && onReady) {
hasSignaledReady.current = true;
onReady();
}
}, [
pagination,
sorting,
debouncedFilters,
onReady,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
]);
const stopPropagation = useCallback((e) => {
e.stopPropagation();
@ -888,8 +932,10 @@ const ChannelsTable = ({}) => {
// columns from being recreated during drag operations (which causes infinite loops).
// The column.size values are only used for INITIAL sizing - TanStack Table manages
// the actual sizes through its own state after initialization.
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
// from the store, so we don't need to recreate columns when logos load.
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedProfileId, channelGroups, logos, theme]
[selectedProfileId, channelGroups, theme]
);
const renderHeaderCell = (header) => {
@ -1326,16 +1372,20 @@ const ChannelsTable = ({}) => {
deleteChannels={deleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
showDisabled={showDisabled}
setShowDisabled={setShowDisabled}
showOnlyStreamlessChannels={showOnlyStreamlessChannels}
setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels}
/>
{/* Table or ghost empty state inside Paper */}
<Box>
{Object.keys(channels).length === 0 && (
{channelsTableLength === 0 && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>
{Object.keys(channels).length > 0 && (
{channelsTableLength > 0 && (
<Box
style={{
display: 'flex',

View file

@ -12,20 +12,22 @@ import {
Text,
TextInput,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import {
ArrowDown01,
Binary,
Check,
CircleCheck,
Ellipsis,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
Settings,
Eye,
EyeOff,
Filter,
Square,
SquareCheck,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@ -102,6 +104,10 @@ const ChannelTableHeader = ({
editChannel,
deleteChannels,
selectedTableIds,
showDisabled,
setShowDisabled,
showOnlyStreamlessChannels,
setShowOnlyStreamlessChannels,
}) => {
const theme = useMantineTheme();
@ -208,6 +214,14 @@ const ChannelTableHeader = ({
);
};
const toggleShowDisabled = () => {
setShowDisabled(!showDisabled);
};
const toggleShowOnlyStreamlessChannels = () => {
setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
};
return (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
@ -236,6 +250,41 @@ const ChannelTableHeader = ({
}}
>
<Flex gap={6}>
<Menu shadow="md" width={200}>
<Menu.Target>
<Button size="xs" variant="default" onClick={() => {}}>
<Filter size={18} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={toggleShowDisabled}
leftSection={
showDisabled ? <Eye size={18} /> : <EyeOff size={18} />
}
disabled={selectedProfileId === '0'}
>
<Text size="xs">
{showDisabled ? 'Hide Disabled' : 'Show Disabled'}
</Text>
</Menu.Item>
<Menu.Item
onClick={toggleShowOnlyStreamlessChannels}
leftSection={
showOnlyStreamlessChannels ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Only Empty Channels</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Button
leftSection={<SquarePen size={18} />}
variant="default"

View file

@ -1,4 +1,10 @@
import React, { useEffect, useMemo, useCallback, useState } from 'react';
import React, {
useEffect,
useMemo,
useCallback,
useState,
useRef,
} from 'react';
import API from '../../api';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
@ -167,8 +173,9 @@ const StreamRowActions = ({
);
};
const StreamsTable = () => {
const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
const hasSignaledReady = useRef(false);
/**
* useState
@ -430,6 +437,12 @@ const StreamsTable = () => {
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
// Signal that initial data load is complete
if (!hasSignaledReady.current && onReady) {
hasSignaledReady.current = true;
onReady();
}
} catch (error) {
console.error('Error fetching data:', error);
}
@ -442,6 +455,7 @@ const StreamsTable = () => {
groupsLoaded,
channelGroups,
fetchChannelGroups,
onReady,
]);
// Bulk creation: create channels from selected streams asynchronously
@ -1157,7 +1171,6 @@ const StreamsTable = () => {
value={customStartNumber}
onChange={setCustomStartNumber}
min={1}
max={9999}
placeholder="Enter starting number..."
/>
)}
@ -1227,7 +1240,6 @@ const StreamsTable = () => {
value={specificChannelNumber}
onChange={setSpecificChannelNumber}
min={1}
max={9999}
placeholder="Enter channel number..."
/>
)}

View file

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

View file

@ -1,19 +1,59 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Box } from '@mantine/core';
import { Allotment } from 'allotment';
import { USER_LEVELS } from '../constants';
import useAuthStore from '../store/auth';
import useLogosStore from '../store/logos';
import useLocalStorage from '../hooks/useLocalStorage';
import ErrorBoundary from '../components/ErrorBoundary';
const ChannelsPage = () => {
const PageContent = () => {
const authUser = useAuthStore((s) => s.user);
const fetchChannelAssignableLogos = useLogosStore(
(s) => s.fetchChannelAssignableLogos
);
const enableLogoRendering = useLogosStore((s) => s.enableLogoRendering);
const channelsReady = useRef(false);
const streamsReady = useRef(false);
const logosTriggered = useRef(false);
const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
'channels-splitter-sizes',
[50, 50]
);
// Only load logos when BOTH tables are ready
const tryLoadLogos = useCallback(() => {
if (
channelsReady.current &&
streamsReady.current &&
!logosTriggered.current
) {
logosTriggered.current = true;
// Use requestAnimationFrame to defer logo loading until after browser paint
// This ensures EPG column is fully rendered before logos start loading
requestAnimationFrame(() => {
requestAnimationFrame(() => {
enableLogoRendering();
fetchChannelAssignableLogos();
});
});
}
}, [fetchChannelAssignableLogos, enableLogoRendering]);
const handleChannelsReady = useCallback(() => {
channelsReady.current = true;
tryLoadLogos();
}, [tryLoadLogos]);
const handleStreamsReady = useCallback(() => {
streamsReady.current = true;
tryLoadLogos();
}, [tryLoadLogos]);
const handleSplitChange = (sizes) => {
setAllotmentSizes(sizes);
};
@ -22,46 +62,49 @@ const ChannelsPage = () => {
setAllotmentSizes(sizes);
};
if (!authUser.id) {
return <></>;
}
if (!authUser.id) return <></>;
if (authUser.user_level <= USER_LEVELS.STANDARD) {
handleStreamsReady();
return (
<Box style={{ padding: 10 }}>
<ChannelsTable />
<ChannelsTable onReady={handleChannelsReady} />
</Box>
);
}
return (
<div
style={{
height: '100vh',
width: '100%',
display: 'flex',
overflowX: 'auto',
}}
>
<Box h={'100vh'} w={'100%'} display={'flex'} style={{ overflowX: 'auto' }}>
<Allotment
defaultSizes={allotmentSizes}
style={{ height: '100%', width: '100%', minWidth: '600px' }}
h={'100%'}
w={'100%'}
miw={'600px'}
className="custom-allotment"
minSize={100}
onChange={handleSplitChange}
onResize={handleResize}
>
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
<div style={{ minWidth: '600px' }}>
<ChannelsTable />
</div>
</div>
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
<div style={{ minWidth: '600px' }}>
<StreamsTable />
</div>
</div>
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
<Box miw={'600px'}>
<ChannelsTable onReady={handleChannelsReady} />
</Box>
</Box>
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
<Box miw={'600px'}>
<StreamsTable onReady={handleStreamsReady} />
</Box>
</Box>
</Allotment>
</div>
</Box>
);
};
const ChannelsPage = () => {
return (
<ErrorBoundary>
<PageContent />
</ErrorBoundary>
);
};

View file

@ -2,16 +2,18 @@ import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import EPGsTable from '../components/tables/EPGsTable';
import { Box, Stack } from '@mantine/core';
import ErrorBoundary from '../components/ErrorBoundary'
const M3UPage = () => {
const PageContent = () => {
const error = useUserAgentsStore((state) => state.error);
if (error) return <div>Error: {error}</div>;
if (error) throw new Error(error);
return (
<Stack
p="10"
h="100%" // Set a specific height to ensure proper display
miw="1100px" // Prevent tables from becoming too cramped
style={{
padding: 10,
height: '100%', // Set a specific height to ensure proper display
minWidth: '1100px', // Prevent tables from becoming too cramped
overflowX: 'auto', // Enable horizontal scrolling when needed
overflowY: 'auto', // Enable vertical scrolling on the container
}}
@ -26,6 +28,14 @@ const M3UPage = () => {
</Box>
</Stack>
);
};
}
const M3UPage = () => {
return (
<ErrorBoundary>
<PageContent/>
</ErrorBoundary>
);
}
export default M3UPage;

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
// src/components/Dashboard.js
import React, { useState } from 'react';
const Dashboard = () => {
const [newStream, setNewStream] = useState('');
return (
<div>
<h1>Dashboard Page</h1>
<input
type="text"
value={newStream}
onChange={(e) => setNewStream(e.target.value)}
placeholder="Enter Stream"
/>
<h3>Streams:</h3>
<ul>
{state.streams.map((stream, index) => (
<li key={index}>{stream}</li>
))}
</ul>
</div>
);
};
export default Dashboard;

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
// src/components/Home.js
import React, { useState } from 'react';
const Home = () => {
const [newChannel, setNewChannel] = useState('');
return (
<div>
<h1>Home Page</h1>
</div>
);
};
export default Home;

View file

@ -1,13 +1,21 @@
import React from 'react';
import React, { lazy, Suspense } from 'react';
import LoginForm from '../components/forms/LoginForm';
import SuperuserForm from '../components/forms/SuperuserForm';
const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm'));
import useAuthStore from '../store/auth';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
import { Text } from '@mantine/core';
const Login = ({}) => {
const superuserExists = useAuthStore((s) => s.superuserExists);
if (!superuserExists) {
return <SuperuserForm />;
return (
<ErrorBoundary>
<Suspense fallback={<Text>Loading...</Text>}>
<SuperuserForm />
</Suspense>
</ErrorBoundary>
);
}
return <LoginForm />;

View file

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

View file

@ -1,353 +1,108 @@
import React, { useEffect, useState } from 'react';
import React, {
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
AppShell,
Box,
ActionIcon,
Alert,
AppShellMain,
Box,
Button,
Card,
Divider,
FileInput,
Group,
Loader,
Modal,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
NumberInput,
Select,
Divider,
ActionIcon,
SimpleGrid,
Modal,
FileInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { RefreshCcw, Trash2 } from 'lucide-react';
import API from '../api';
import { notifications } from '@mantine/notifications';
import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
import { usePluginStore } from '../store/plugins.jsx';
import {
deletePluginByKey,
importPlugin,
runPluginAction,
setPluginEnabled,
updatePluginSettings,
} from '../utils/pages/PluginsUtils.js';
import { RefreshCcw } from 'lucide-react';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const PluginCard = React.lazy(() =>
import('../components/cards/PluginCard.jsx'));
const Field = ({ field, value, onChange }) => {
const common = { label: field.label, description: field.help_text };
const effective = value ?? field.default;
switch (field.type) {
case 'boolean':
return (
<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 PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const plugins = usePluginStore((state) => state.plugins);
const loading = usePluginStore((state) => state.loading);
const hasFetchedRef = useRef(false);
const PluginCard = ({
plugin,
onSaveSettings,
onRunAction,
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,
});
useEffect(() => {
if (!hasFetchedRef.current) {
hasFetchedRef.current = true;
usePluginStore.getState().fetchPlugins();
}
}, []);
// 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 handleTogglePluginEnabled = async (key, next) => {
const resp = await setPluginEnabled(key, next);
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
};
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
notifications.show({
title: 'Saved',
message: `${plugin.name} settings updated`,
color: 'green',
if (resp?.success) {
usePluginStore.getState().updatePlugin(key, {
enabled: next,
ever_enabled: resp?.ever_enabled,
});
} finally {
setSaving(false);
}
};
const missing = plugin.missing;
if (loading && plugins.length === 0) {
return <Loader />;
}
return (
<Card
shadow="sm"
radius="md"
withBorder
style={{ 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}
<>
{plugins.length > 0 &&
<SimpleGrid
cols={2}
spacing="md"
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
{plugins.map((p) => (
<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>
</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={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>
</Box>
)}
{!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() {
const [loading, setLoading] = useState(true);
const [plugins, setPlugins] = useState([]);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
@ -358,118 +113,172 @@ export default function PluginsPage() {
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
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 () => {
setLoading(true);
try {
const list = await API.getPlugins();
setPlugins(list);
} finally {
setLoading(false);
}
const handleReload = () => {
usePluginStore.getState().invalidatePlugins();
};
useEffect(() => {
load();
const handleRequestDelete = useCallback((pl) => {
setDeleteTarget(pl);
setDeleteOpen(true);
}, []);
const requireTrust = (plugin) => {
const requireTrust = useCallback((plugin) => {
return new Promise((resolve) => {
setTrustResolve(() => resolve);
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 (
<AppShell.Main style={{ padding: 16 }}>
<AppShellMain p={16}>
<Group justify="space-between" mb="md">
<Text fw={700} size="lg">
Plugins
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={() => {
setImportOpen(true);
setImported(null);
setImportFile(null);
setEnableAfterImport(false);
}}
>
<Button size="xs" variant="light" onClick={showImportForm}>
Import Plugin
</Button>
<ActionIcon
variant="light"
onClick={async () => {
await API.reloadPlugins();
await load();
}}
title="Reload"
>
<ActionIcon variant="light" onClick={handleReload} title="Reload">
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
{loading ? (
<Loader />
) : (
<>
<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>
)}
</>
)}
<PluginsList
onRequestDelete={handleRequestDelete}
onRequireTrust={requireTrust}
onRequestConfirm={requestConfirm}
/>
{/* Import Plugin Modal */}
<Modal
opened={importOpen}
@ -520,61 +329,7 @@ export default function PluginsPage() {
size="xs"
loading={importing}
disabled={!importFile}
onClick={async () => {
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);
}
}}
onClick={handleImportPlugin()}
>
Upload
</Button>
@ -612,36 +367,7 @@ export default function PluginsPage() {
<Button
size="xs"
disabled={!enableAfterImport}
onClick={async () => {
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);
}
}}
onClick={handleEnablePlugin()}
>
Enable
</Button>
@ -727,33 +453,37 @@ export default function PluginsPage() {
size="xs"
color="red"
loading={deleting}
onClick={async () => {
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);
}
}}
onClick={handleDeletePlugin()}
>
Delete
</Button>
</Group>
</Stack>
</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

@ -481,8 +481,8 @@ const VODCard = ({ vodContent, stopVODClient }) => {
size={16}
style={{
transform: isClientExpanded
? 'rotate(180deg)'
: 'rotate(0deg)',
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}}
/>

View file

@ -1,55 +1,25 @@
import React, { useState } from 'react';
import UsersTable from '../components/tables/UsersTable';
import { Box } from '@mantine/core';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import ErrorBoundary from '../components/ErrorBoundary';
const UsersPage = () => {
const PageContent = () => {
const authUser = useAuthStore((s) => s.user);
const [selectedUser, setSelectedUser] = useState(null);
const [userModalOpen, setUserModalOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [userToDelete, setUserToDelete] = useState(null);
if (!authUser.id) {
return <></>;
}
const closeUserModal = () => {
setSelectedUser(null);
setUserModalOpen(false);
};
const editUser = (user) => {
setSelectedUser(user);
setUserModalOpen(true);
};
const deleteUser = (id) => {
// Get user details for the confirmation dialog
const user = users.find((u) => u.id === id);
setUserToDelete(user);
setDeleteTarget(id);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-user')) {
return executeDeleteUser(id);
}
setConfirmDeleteOpen(true);
};
const executeDeleteUser = async (id) => {
await API.deleteUser(id);
setConfirmDeleteOpen(false);
};
if (!authUser.id) throw new Error();
return (
<Box style={{ padding: 10 }}>
<Box p={10}>
<UsersTable />
</Box>
);
}
const UsersPage = () => {
return (
<ErrorBoundary>
<PageContent/>
</ErrorBoundary>
);
};
export default UsersPage;

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 EXPANDED_PROGRAM_HEIGHT = 180;
/** Layout constants */
export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const map = new Map();
@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
return map;
}
export function mapProgramsByChannel(programs, channelIdByTvgId) {
export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
if (!programs?.length || !channelIdByTvgId?.size) {
return new Map();
}
const map = new Map();
const nowMs = getNowMs();
programs.forEach((program) => {
const channelIds = channelIdByTvgId.get(String(program.tvg_id));
if (!channelIds || channelIds.length === 0) {
return;
}
const startMs = program.startMs ?? dayjs(program.start_time).valueOf();
const endMs = program.endMs ?? dayjs(program.end_time).valueOf();
const startMs = program.startMs ?? convertToMs(program.start_time);
const endMs = program.endMs ?? convertToMs(program.end_time);
const programData = {
...program,
startMs,
endMs,
programStart: initializeTime(program.startMs),
programEnd: initializeTime(program.endMs),
// Precompute live/past status
isLive: nowMs >= program.startMs && nowMs < program.endMs,
isPast: nowMs >= program.endMs,
};
// Add this program to all channels that share the same TVG ID
@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) {
});
return map;
}
};
export function computeRowHeights(
filteredChannels,
@ -94,3 +120,282 @@ export function computeRowHeights(
return expanded ? expandedHeight : defaultHeight;
});
}
export const fetchPrograms = async () => {
console.log('Fetching program grid...');
const fetched = await API.getGrid(); // GETs your EPG grid
console.log(`Received ${fetched.length} programs`);
return fetched.map((program) => {
return {
...program,
startMs: convertToMs(program.start_time),
endMs: convertToMs(program.end_time),
};
});
};
export const sortChannels = (channels) => {
// Include ALL channels, sorted by channel number - don't filter by EPG data
const sortedChannels = Object.values(channels).sort(
(a, b) =>
(a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(`Using all ${sortedChannels.length} available channels`);
return sortedChannels;
}
export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
return guideChannels.filter((channel) => {
// Search filter
if (searchQuery) {
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
}
// Channel group filter
if (selectedGroupId !== 'all') {
if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
}
// Profile filter
if (selectedProfileId !== 'all') {
const profileChannels = profiles[selectedProfileId]?.channels || [];
const enabledChannelIds = Array.isArray(profileChannels)
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
: profiles[selectedProfileId]?.channels instanceof Set
? Array.from(profiles[selectedProfileId].channels)
: [];
if (!enabledChannelIds.includes(channel.id)) return false;
}
return true;
});
}
export const calculateEarliestProgramStart = (programs, defaultStart) => {
if (!programs.length) return defaultStart;
return programs.reduce((acc, p) => {
const s = initializeTime(p.start_time);
return isBefore(s, acc) ? s : acc;
}, defaultStart);
}
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
if (!programs.length) return defaultEnd;
return programs.reduce((acc, p) => {
const e = initializeTime(p.end_time);
return isAfter(e, acc) ? e : acc;
}, defaultEnd);
}
export const calculateStart = (earliestProgramStart, defaultStart) => {
return isBefore(earliestProgramStart, defaultStart)
? earliestProgramStart
: defaultStart;
}
export const calculateEnd = (latestProgramEnd, defaultEnd) => {
return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
}
export const mapChannelsById = (guideChannels) => {
const map = new Map();
guideChannels.forEach((channel) => {
map.set(channel.id, channel);
});
return map;
}
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();
(recordings || []).forEach((recording) => {
const programId = recording?.custom_properties?.program?.id;
if (programId != null) {
map.set(programId, recording);
}
});
return map;
}
export const formatTime = (time, dateFormat) => {
const today = startOfDay(getNow());
const tomorrow = add(today, 1, 'day');
const weekLater = add(today, 7, 'day');
const day = startOfDay(time);
if (isSame(day, today, 'day')) {
return 'Today';
} else if (isSame(day, tomorrow, 'day')) {
return 'Tomorrow';
} else if (isBefore(day, weekLater)) {
// Within a week, show day name
return format(time, 'dddd');
} else {
// Beyond a week, show month and day
return format(time, dateFormat);
}
}
export const calculateHourTimeline = (start, end, formatDayLabel) => {
const hours = [];
let current = start;
let currentDay = null;
while (isBefore(current, end)) {
// Check if we're entering a new day
const day = startOfDay(current);
const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
if (isNewDay) {
currentDay = day;
}
// Add day information to our hour object
hours.push({
time: current,
isNewDay,
dayLabel: formatDayLabel(current),
});
current = add(current, 1, 'hour');
}
return hours;
}
export const calculateNowPosition = (now, start, end) => {
if (isBefore(now, start) || isAfter(now, end)) return -1;
const minutesSinceStart = diff(now, start, 'minute');
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
};
export const calculateScrollPosition = (now, start) => {
const roundedNow = roundToNearest(now, 30);
const nowOffset = diff(roundedNow, start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
return Math.max(scrollPosition, 0);
};
export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
const channelIds = channelIdByTvgId.get(String(tvgId));
if (!channelIds || channelIds.length === 0) {
return null;
}
// Return the first channel that matches this TVG ID
return channelById.get(channelIds[0]) || null;
}
export const fetchRules = async () => {
return await API.listSeriesRules();
}
export const getRuleByProgram = (rules, program) => {
return (rules || []).find(
(r) =>
String(r.tvg_id) === String(program.tvg_id) &&
(!r.title || r.title === program.title)
);
}
export const createRecording = async (channel, program) => {
await API.createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
}
export const createSeriesRule = async (program, mode) => {
await API.createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
}
export const evaluateSeriesRule = async (program) => {
await API.evaluateSeriesRules(program.tvg_id);
}
export const calculateLeftScrollPosition = (program, start) => {
const programStartMs =
program.startMs ?? convertToMs(program.start_time);
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
};
export const calculateDesiredScrollPosition = (leftPx) => {
return Math.max(0, leftPx - 20);
}
export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
const rect = event.currentTarget.getBoundingClientRect();
const clickPositionX = event.clientX - rect.left;
const percentageAcross = clickPositionX / rect.width;
const minuteWithinHour = percentageAcross * 60;
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
const adjustedTime = (snappedMinute === 60)
? add(clickedTime, 1, 'hour').minute(0)
: clickedTime.minute(snappedMinute);
const snappedOffset = diff(adjustedTime, start, 'minute');
return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
};
export const getGroupOptions = (channelGroups, guideChannels) => {
const options = [{ value: 'all', label: 'All Channel Groups' }];
if (channelGroups && guideChannels.length > 0) {
// Get unique channel group IDs from the channels that have program data
const usedGroupIds = new Set();
guideChannels.forEach((channel) => {
if (channel.channel_group_id) {
usedGroupIds.add(channel.channel_group_id);
}
});
// Only add groups that are actually used by channels in the guide
Object.values(channelGroups)
.filter((group) => usedGroupIds.has(group.id))
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
.forEach((group) => {
options.push({
value: group.id.toString(),
label: group.name,
});
});
}
return options;
}
export const getProfileOptions = (profiles) => {
const options = [{ value: 'all', label: 'All Profiles' }];
if (profiles) {
Object.values(profiles).forEach((profile) => {
if (profile.id !== '0') {
// Skip the 'All' default profile
options.push({
value: profile.id.toString(),
label: profile.name,
});
}
});
}
return options;
}
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
await API.deleteSeriesRule(tvg_id);
}
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
await API.evaluateSeriesRules(tvg_id);
}

View file

@ -7,7 +7,6 @@ import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
import useUserAgentsStore from './userAgents';
import useUsersStore from './users';
import useLogosStore from './logos';
import API from '../api';
import { USER_LEVELS } from '../constants';
@ -43,6 +42,8 @@ const useAuthStore = create((set, get) => ({
throw new Error('Unauthorized');
}
set({ user, isAuthenticated: true });
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
@ -63,7 +64,8 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
set({ user, isAuthenticated: true });
// Note: Logos are loaded after the Channels page tables finish loading
// This is handled by the tables themselves signaling completion
} catch (error) {
console.error('Error initializing data:', error);
}

View file

@ -9,16 +9,10 @@ const useLogosStore = create((set, get) => ({
hasLoadedAll: false, // Track if we've loaded all logos
hasLoadedChannelLogos: false, // Track if we've loaded channel logos
error: null,
allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
// Basic CRUD operations
setLogos: (logos) => {
set({
logos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
});
},
// Enable logo rendering (call this after tables have loaded and painted)
enableLogoRendering: () => set({ allowLogoRendering: true }),
addLogo: (newLogo) =>
set((state) => {
@ -73,6 +67,9 @@ const useLogosStore = create((set, get) => ({
// Smart loading methods
fetchLogos: async (pageSize = 100) => {
// Don't fetch if logo fetching is not allowed yet
if (!get().allowLogoFetching) return [];
set({ isLoading: true, error: null });
try {
const response = await api.getLogos({ page_size: pageSize });
@ -163,59 +160,28 @@ const useLogosStore = create((set, get) => ({
},
fetchChannelAssignableLogos: async () => {
const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get();
const { hasLoadedChannelLogos, channelLogos } = get();
// Prevent concurrent calls
if (
backgroundLoading ||
(hasLoadedChannelLogos && Object.keys(channelLogos).length > 0)
) {
// Return cached if already loaded
if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
return Object.values(channelLogos);
}
set({ backgroundLoading: true, error: null });
try {
// Load all channel logos (no special filtering needed - all Logo entries are for channels)
const response = await api.getLogos({
no_pagination: 'true', // Get all channel logos
});
// Fetch all logos and cache them as channel logos
const logos = await get().fetchAllLogos();
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
set({
channelLogos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
hasLoadedChannelLogos: true,
});
console.log(`Fetched ${logos.length} channel logos`);
// Store in both places, but this is intentional and only when specifically requested
set({
logos: {
...get().logos, // Keep existing logos
...logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
},
channelLogos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
hasLoadedChannelLogos: true,
backgroundLoading: false,
});
return logos;
} catch (error) {
console.error('Failed to fetch channel logos:', error);
set({
error: 'Failed to load channel logos.',
backgroundLoading: false,
});
throw error;
}
return logos;
},
fetchLogosByIds: async (logoIds) => {
if (!logoIds || logoIds.length === 0) return [];
try {
// Filter out logos we already have
const missingIds = logoIds.filter((id) => !get().logos[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

@ -0,0 +1,92 @@
import API from '../../api.js';
import useChannelsStore from '../../store/channels.jsx';
export const removeRecording = (id) => {
// Optimistically remove immediately from UI
try {
useChannelsStore.getState().removeRecording(id);
} catch (error) {
console.error('Failed to optimistically remove recording', error);
}
// Fire-and-forget server delete; websocket will keep others in sync
API.deleteRecording(id).catch(() => {
// On failure, fallback to refetch to restore state
try {
useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.error('Failed to refresh recordings after delete', error);
}
});
};
export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
let purl = posterLogoId
? `/api/channels/logos/${posterLogoId}/cache/`
: customProperties?.poster_url || posterUrl || '/logo.png';
if (
typeof import.meta !== 'undefined' &&
import.meta.env &&
import.meta.env.DEV &&
purl &&
purl.startsWith('/')
) {
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
}
return purl;
};
export const getShowVideoUrl = (channel, env_mode) => {
let url = `/proxy/ts/stream/${channel.uuid}`;
if (env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
return url;
};
export const runComSkip = async (recording) => {
await API.runComskip(recording.id);
};
export const deleteRecordingById = async (recordingId) => {
await API.deleteRecording(recordingId);
};
export const deleteSeriesAndRule = async (seriesInfo) => {
const { tvg_id, title } = seriesInfo;
if (tvg_id) {
try {
await API.bulkRemoveSeriesRecordings({
tvg_id,
title,
scope: 'title',
});
} catch (error) {
console.error('Failed to remove series recordings', error);
}
try {
await API.deleteSeriesRule(tvg_id);
} catch (error) {
console.error('Failed to delete series rule', error);
}
}
};
export const getRecordingUrl = (customProps, env_mode) => {
let fileUrl = customProps?.file_url || customProps?.output_file_url;
if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) {
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
}
return fileUrl;
};
export const getSeasonLabel = (season, episode, onscreen) => {
return season && episode
? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
: onscreen || null;
};
export const getSeriesInfo = (customProps) => {
const cp = customProps || {};
const pr = cp.program || {};
return { tvg_id: pr.tvg_id, title: pr.title };
};

View file

@ -0,0 +1,258 @@
import { useCallback, useEffect } from 'react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import useSettingsStore from '../store/settings';
import useLocalStorage from '../hooks/useLocalStorage';
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const initializeTime = (dateTime) => dayjs(dateTime);
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2);
export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2);
export const isSame = (date1, date2, unit = 'day') =>
dayjs(date1).isSame(date2, unit);
export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
export const diff = (date1, date2, unit = 'millisecond') =>
dayjs(date1).diff(date2, unit);
export const format = (dateTime, formatStr) =>
dayjs(dateTime).format(formatStr);
export const getNow = () => dayjs();
export const getNowMs = () => Date.now();
export const roundToNearest = (dateTime, minutes) => {
const current = initializeTime(dateTime);
const minute = current.minute();
const snappedMinute = Math.round(minute / minutes) * minutes;
return snappedMinute === 60
? current.add(1, 'hour').minute(0)
: current.minute(snappedMinute);
};
export const useUserTimeZone = () => {
const settings = useSettingsStore((s) => s.settings);
const [timeZone, setTimeZone] = useLocalStorage(
'time-zone',
dayjs.tz?.guess
? dayjs.tz.guess()
: Intl.DateTimeFormat().resolvedOptions().timeZone
);
useEffect(() => {
const tz = settings?.['system-time-zone']?.value;
if (tz && tz !== timeZone) {
setTimeZone(tz);
}
}, [settings, timeZone, setTimeZone]);
return timeZone;
};
export const useTimeHelpers = () => {
const timeZone = useUserTimeZone();
const toUserTime = useCallback(
(value) => {
if (!value) return dayjs.invalid();
try {
return initializeTime(value).tz(timeZone);
} catch (error) {
return initializeTime(value);
}
},
[timeZone]
);
const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
return { timeZone, toUserTime, userNow };
};
export const RECURRING_DAY_OPTIONS = [
{ value: 6, label: 'Sun' },
{ value: 0, label: 'Mon' },
{ value: 1, label: 'Tue' },
{ value: 2, label: 'Wed' },
{ value: 3, label: 'Thu' },
{ value: 4, label: 'Fri' },
{ value: 5, label: 'Sat' },
];
export const useDateTimeFormat = () => {
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
// Use user preference for time format
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
return [timeFormat, dateFormat];
};
export const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true);
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
const parsed = initializeTime(value);
return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
};
export const parseDate = (value) => {
if (!value) return null;
const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
return parsed.isValid() ? parsed.toDate() : null;
};
const TIMEZONE_FALLBACKS = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'America/Anchorage',
'Pacific/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Warsaw',
'Europe/Moscow',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Seoul',
'Australia/Sydney',
];
const getSupportedTimeZones = () => {
try {
if (typeof Intl.supportedValuesOf === 'function') {
return Intl.supportedValuesOf('timeZone');
}
} catch (error) {
console.warn('Unable to enumerate supported time zones:', error);
}
return TIMEZONE_FALLBACKS;
};
const getTimeZoneOffsetMinutes = (date, timeZone) => {
try {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const parts = dtf.formatToParts(date).reduce((acc, part) => {
if (part.type !== 'literal') acc[part.type] = part.value;
return acc;
}, {});
const asUTC = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
return (asUTC - date.getTime()) / 60000;
} catch (error) {
console.warn(`Failed to compute offset for ${timeZone}:`, error);
return 0;
}
};
const formatOffset = (minutes) => {
const rounded = Math.round(minutes);
const sign = rounded < 0 ? '-' : '+';
const absolute = Math.abs(rounded);
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
const mins = String(absolute % 60).padStart(2, '0');
return `UTC${sign}${hours}:${mins}`;
};
export const buildTimeZoneOptions = (preferredZone) => {
const zones = getSupportedTimeZones();
const referenceYear = new Date().getUTCFullYear();
const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
const options = zones
.map((zone) => {
const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
const minOffset = Math.min(janOffset, julOffset);
const maxOffset = Math.max(janOffset, julOffset);
const usesDst = minOffset !== maxOffset;
const labelParts = [`now ${formatOffset(currentOffset)}`];
if (usesDst) {
labelParts.push(
`DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
);
}
return {
value: zone,
label: `${zone} (${labelParts.join(' | ')})`,
numericOffset: minOffset,
};
})
.sort((a, b) => {
if (a.numericOffset !== b.numericOffset) {
return a.numericOffset - b.numericOffset;
}
return a.value.localeCompare(b.value);
});
if (
preferredZone &&
!options.some((option) => option.value === preferredZone)
) {
const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
options.push({
value: preferredZone,
label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
numericOffset: currentOffset,
});
options.sort((a, b) => {
if (a.numericOffset !== b.numericOffset) {
return a.numericOffset - b.numericOffset;
}
return a.value.localeCompare(b.value);
});
}
return options;
};
export const getDefaultTimeZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (error) {
return 'UTC';
}
};

View file

@ -0,0 +1,87 @@
export const getStatRows = (stats) => {
return [
['Video Codec', stats.video_codec],
[
'Resolution',
stats.resolution ||
(stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
],
['FPS', stats.source_fps],
['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
['Audio Codec', stats.audio_codec],
['Audio Channels', stats.audio_channels],
['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
].filter(([, v]) => v !== null && v !== undefined && v !== '');
};
export const getRating = (customProps, program) => {
return (
customProps.rating ||
customProps.rating_value ||
(program && program.custom_properties && program.custom_properties.rating)
);
};
const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
return arr.filter((r) => {
const cp = r.custom_properties || {};
const pr = cp.program || {};
if ((pr.tvg_id || '') !== tvid) return false;
if ((pr.title || '').toLowerCase() !== titleKey) return false;
const st = toUserTime(r.start_time);
return st.isAfter(userNow());
});
}
const dedupeByProgram = (filtered) => {
// Deduplicate by program.id if present, else by time+title
const seen = new Set();
const deduped = [];
for (const r of filtered) {
const cp = r.custom_properties || {};
const pr = cp.program || {};
// Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
let key = null;
if (season != null && episode != null) key = `se:${season}:${episode}`;
else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
else if (pr.id != null) key = `id:${pr.id}`;
else
key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
}
return deduped;
}
export const getUpcomingEpisodes = (
isSeriesGroup,
allRecordings,
program,
toUserTime,
userNow
) => {
if (!isSeriesGroup) return [];
const arr = Array.isArray(allRecordings)
? allRecordings
: Object.values(allRecordings || {});
const tvid = program.tvg_id || '';
const titleKey = (program.title || '').toLowerCase();
const filtered = filterByUpcoming(arr, tvid, titleKey, toUserTime, userNow);
return dedupeByProgram(filtered).sort(
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
);
};

View file

@ -0,0 +1,66 @@
import API from '../../api.js';
import { toTimeString } from '../dateTimeUtils.js';
import dayjs from 'dayjs';
export const getChannelOptions = (channels) => {
return Object.values(channels || {})
.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum === bNum) {
return (a.name || '').localeCompare(b.name || '');
}
return aNum - bNum;
})
.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
}));
};
export const getUpcomingOccurrences = (
recordings,
userNow,
ruleId,
toUserTime
) => {
const list = Array.isArray(recordings)
? recordings
: Object.values(recordings || {});
const now = userNow();
return list
.filter(
(rec) =>
rec?.custom_properties?.rule?.id === ruleId &&
toUserTime(rec.start_time).isAfter(now)
)
.sort(
(a, b) =>
toUserTime(a.start_time).valueOf() - toUserTime(b.start_time).valueOf()
);
};
export const updateRecurringRule = async (ruleId, values) => {
await API.updateRecurringRule(ruleId, {
channel: values.channel_id,
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
start_time: toTimeString(values.start_time),
end_time: toTimeString(values.end_time),
start_date: values.start_date
? dayjs(values.start_date).format('YYYY-MM-DD')
: null,
end_date: values.end_date
? dayjs(values.end_date).format('YYYY-MM-DD')
: null,
name: values.rule_name?.trim() || '',
enabled: Boolean(values.enabled),
});
};
export const deleteRecurringRuleById = async (ruleId) => {
await API.deleteRecurringRule(ruleId);
};
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
};

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,90 @@
// Deduplicate in-progress and upcoming by program id or channel+slot
const dedupeByProgramOrSlot = (arr) => {
const out = [];
const sigs = new Set();
for (const r of arr) {
const cp = r.custom_properties || {};
const pr = cp.program || {};
const sig =
pr?.id != null
? `id:${pr.id}`
: `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
if (sigs.has(sig)) continue;
sigs.add(sig);
out.push(r);
}
return out;
};
const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
// ID-based dedupe guard in case store returns duplicates
const seenIds = new Set();
for (const rec of list) {
if (rec && rec.id != null) {
const k = String(rec.id);
if (seenIds.has(k)) continue;
seenIds.add(k);
}
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
const status = rec.custom_properties?.status;
if (status === 'interrupted' || status === 'completed') {
completed.push(rec);
} else {
if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
else if (now.isBefore(s)) upcoming.push(rec);
else completed.push(rec);
}
}
}
export const categorizeRecordings = (recordings, toUserTime, now) => {
const inProgress = [];
const upcoming = [];
const completed = [];
const list = Array.isArray(recordings)
? recordings
: Object.values(recordings || {});
dedupeById(list, toUserTime, completed, now, inProgress, upcoming);
const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
(a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
);
// Group upcoming by series title+tvg_id (keep only next episode)
const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
);
const grouped = new Map();
for (const rec of upcomingDedup) {
const cp = rec.custom_properties || {};
const prog = cp.program || {};
const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
if (!grouped.has(key)) {
grouped.set(key, { rec, count: 1 });
} else {
const entry = grouped.get(key);
entry.count += 1;
}
}
const upcomingGrouped = Array.from(grouped.values()).map((e) => {
const item = { ...e.rec };
item._group_count = e.count;
return item;
});
completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
return {
inProgress: inProgressDedup,
upcoming: upcomingGrouped,
completed,
};
}

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