mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
commit
a505138e1a
65 changed files with 4079 additions and 1110 deletions
73
CHANGELOG.md
73
CHANGELOG.md
|
|
@ -7,12 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities:
|
||||
- **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx))
|
||||
- **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7))
|
||||
- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router)
|
||||
- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details.
|
||||
|
||||
### Added
|
||||
|
||||
- Editable Channel Table Mode:
|
||||
- Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
|
||||
- EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
|
||||
- Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. (Closes #333)
|
||||
- Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
|
||||
- All changes are saved via API with optimistic UI updates and error handling.
|
||||
- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include:
|
||||
- Currently playing program title displayed with live broadcast indicator (green Radio icon)
|
||||
- Expandable program descriptions via chevron button
|
||||
- Progress bar showing elapsed and remaining time for currently playing programs
|
||||
- Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels
|
||||
- Smart scheduling that fetches new program data 5 seconds after current program ends
|
||||
- Only polls when active channel list changes, not on stats refresh
|
||||
- Channel preview button: Added preview functionality to active stream cards on stats page
|
||||
- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667)
|
||||
- Streams table: Added "Hide Stale" filter to quickly hide streams marked as stale.
|
||||
- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom)
|
||||
- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery.
|
||||
- Mature content filtering support:
|
||||
- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
|
||||
- Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
|
||||
- Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers
|
||||
- UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management
|
||||
- XtreamCodes API support with proper integer formatting (0/1) in live stream responses
|
||||
- Automatic propagation from streams to channels during both single and bulk channel creation operations
|
||||
- Included in serializers for full API support
|
||||
- User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content)
|
||||
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
|
||||
- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)
|
||||
- Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips.
|
||||
|
||||
### Changed
|
||||
|
||||
- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering:
|
||||
- Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely
|
||||
- Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()`
|
||||
- Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls
|
||||
- Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses
|
||||
- Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches
|
||||
- Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development
|
||||
- Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store
|
||||
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
|
||||
- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology.
|
||||
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
|
||||
- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables.
|
||||
- Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864)
|
||||
- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712)
|
||||
- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877)
|
||||
- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869)
|
||||
- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:
|
||||
- Stream connection card "Connected" column
|
||||
- VOD connection card "Connection Start Time" column
|
||||
- M3U table "Updated" column
|
||||
- EPG table "Updated" column
|
||||
- Users table "Last Login" and "Date Joined" columns
|
||||
- All components now use centralized `format()` helper from dateTimeUtils for consistency
|
||||
- Removed unused imports from table components for cleaner code
|
||||
- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes)
|
||||
- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults.
|
||||
- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.
|
||||
- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613)
|
||||
- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page.
|
||||
- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database.
|
||||
- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles.
|
||||
- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output.
|
||||
- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access.
|
||||
|
||||
|
|
@ -20,6 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator)
|
||||
- Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes.
|
||||
- Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access.
|
||||
- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations.
|
||||
|
|
|
|||
|
|
@ -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 Count, F
|
||||
from django.db.models import Q
|
||||
import os, json, requests, logging, mimetypes
|
||||
from django.utils.http import http_date
|
||||
|
|
@ -97,7 +98,7 @@ class StreamFilter(django_filters.FilterSet):
|
|||
channel_group_name = OrInFilter(
|
||||
field_name="channel_group__name", lookup_expr="icontains"
|
||||
)
|
||||
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
|
||||
m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id")
|
||||
m3u_account_name = django_filters.CharFilter(
|
||||
field_name="m3u_account__name", lookup_expr="icontains"
|
||||
)
|
||||
|
|
@ -148,14 +149,20 @@ class StreamViewSet(viewsets.ModelViewSet):
|
|||
qs = qs.filter(channels__id=assigned)
|
||||
|
||||
unassigned = self.request.query_params.get("unassigned")
|
||||
if unassigned == "1":
|
||||
qs = qs.filter(channels__isnull=True)
|
||||
if unassigned and str(unassigned).lower() in ("1", "true", "yes", "on"):
|
||||
# Use annotation with Count for better performance on large datasets
|
||||
qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0)
|
||||
|
||||
channel_group = self.request.query_params.get("channel_group")
|
||||
if channel_group:
|
||||
group_names = channel_group.split(",")
|
||||
qs = qs.filter(channel_group__name__in=group_names)
|
||||
|
||||
# Allow client to hide stale streams (streams marked as is_stale=True)
|
||||
hide_stale = self.request.query_params.get("hide_stale")
|
||||
if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"):
|
||||
qs = qs.filter(is_stale=False)
|
||||
|
||||
return qs
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
|
@ -195,6 +202,73 @@ class StreamViewSet(viewsets.ModelViewSet):
|
|||
# Return the response with the list of unique group names
|
||||
return Response(list(group_names))
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="filter-options")
|
||||
def get_filter_options(self, request, *args, **kwargs):
|
||||
"""
|
||||
Get available filter options based on current filter state.
|
||||
Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U.
|
||||
"""
|
||||
# For group options: we need to bypass the channel_group custom queryset filtering
|
||||
# Store original request params
|
||||
original_params = request.query_params
|
||||
|
||||
# Create modified params without channel_group for getting group options
|
||||
params_without_group = request.GET.copy()
|
||||
params_without_group.pop('channel_group', None)
|
||||
params_without_group.pop('channel_group_name', None)
|
||||
|
||||
# Temporarily modify request to exclude channel_group
|
||||
request._request.GET = params_without_group
|
||||
base_queryset_for_groups = self.get_queryset()
|
||||
|
||||
# Apply filterset (which will apply M3U filters)
|
||||
group_filterset = self.filterset_class(
|
||||
params_without_group,
|
||||
queryset=base_queryset_for_groups
|
||||
)
|
||||
group_queryset = group_filterset.qs
|
||||
|
||||
group_names = (
|
||||
group_queryset.exclude(channel_group__isnull=True)
|
||||
.order_by("channel_group__name")
|
||||
.values_list("channel_group__name", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# For M3U options: show ALL M3Us (don't filter by anything except name search)
|
||||
params_for_m3u = request.GET.copy()
|
||||
params_for_m3u.pop('m3u_account', None)
|
||||
params_for_m3u.pop('channel_group', None)
|
||||
params_for_m3u.pop('channel_group_name', None)
|
||||
|
||||
# Temporarily modify request to exclude filters for M3U options
|
||||
request._request.GET = params_for_m3u
|
||||
base_queryset_for_m3u = self.get_queryset()
|
||||
|
||||
m3u_filterset = self.filterset_class(
|
||||
params_for_m3u,
|
||||
queryset=base_queryset_for_m3u
|
||||
)
|
||||
m3u_queryset = m3u_filterset.qs
|
||||
|
||||
m3u_accounts = (
|
||||
m3u_queryset.exclude(m3u_account__isnull=True)
|
||||
.order_by("m3u_account__name")
|
||||
.values("m3u_account__id", "m3u_account__name")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Restore original params
|
||||
request._request.GET = original_params
|
||||
|
||||
return Response({
|
||||
"groups": list(group_names),
|
||||
"m3u_accounts": [
|
||||
{"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]}
|
||||
for m3u in m3u_accounts
|
||||
]
|
||||
})
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
|
||||
|
|
@ -519,6 +593,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
if self.request.user.user_level < 10:
|
||||
filters["user_level__lte"] = self.request.user.user_level
|
||||
# Hide adult content if user preference is set
|
||||
custom_props = self.request.user.custom_properties or {}
|
||||
if custom_props.get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
|
||||
if filters:
|
||||
qs = qs.filter(**filters)
|
||||
|
|
@ -881,6 +959,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"tvg_id": stream.tvg_id,
|
||||
"tvc_guide_stationid": tvc_guide_stationid,
|
||||
"streams": [stream_id],
|
||||
"is_adult": stream.is_adult,
|
||||
}
|
||||
|
||||
# Only add channel_group_id if the stream has a channel group
|
||||
|
|
@ -1172,6 +1251,95 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=400)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description=(
|
||||
"Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
|
||||
"The channel will receive the next whole number after the target channel, and all subsequent "
|
||||
"channels will be renumbered accordingly."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"insert_after_id": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER,
|
||||
description="ID of the channel to insert after. Use null to move to the beginning.",
|
||||
nullable=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: "Channel reordered successfully",
|
||||
404: "Channel not found",
|
||||
400: "Invalid request",
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="reorder")
|
||||
def reorder(self, request, pk=None):
|
||||
"""
|
||||
Reorder a channel by moving it after another channel (or to the start if insert_after_id is null).
|
||||
Shifts other channels as needed to maintain contiguous ordering.
|
||||
"""
|
||||
channel = self.get_object()
|
||||
insert_after_id = request.data.get("insert_after_id")
|
||||
old_channel_number = channel.channel_number
|
||||
|
||||
with transaction.atomic():
|
||||
if insert_after_id is None:
|
||||
# Move to the beginning (channel_number = 1)
|
||||
target_number = 0
|
||||
desired_number = 1
|
||||
else:
|
||||
try:
|
||||
target_channel = Channel.objects.get(id=insert_after_id)
|
||||
target_number = target_channel.channel_number or 0
|
||||
desired_number = int(target_number) + 1
|
||||
except Channel.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Target channel not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if desired_number == old_channel_number:
|
||||
# No change needed
|
||||
return Response(
|
||||
{
|
||||
"message": f"Channel {channel.name} already at position {desired_number}",
|
||||
"channel": self.get_serializer(channel).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if desired_number < old_channel_number:
|
||||
# Moving up: increment all channels between desired_number and old_channel_number-1
|
||||
Channel.objects.filter(
|
||||
channel_number__gte=desired_number,
|
||||
channel_number__lt=old_channel_number
|
||||
).update(channel_number=F('channel_number') + 1)
|
||||
channel.channel_number = desired_number
|
||||
channel.save(update_fields=['channel_number'])
|
||||
elif desired_number > old_channel_number:
|
||||
# Moving down: shift down channels between old+1 and desired-1, then set to desired-1
|
||||
if desired_number > old_channel_number + 1:
|
||||
Channel.objects.filter(
|
||||
channel_number__gt=old_channel_number,
|
||||
channel_number__lt=desired_number
|
||||
).update(channel_number=F('channel_number') - 1)
|
||||
channel.channel_number = desired_number - 1
|
||||
channel.save(update_fields=['channel_number'])
|
||||
else:
|
||||
# No move or same position
|
||||
channel.channel_number = desired_number
|
||||
channel.save(update_fields=['channel_number'])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Channel {channel.name} moved to position {desired_number}",
|
||||
"channel": self.get_serializer(channel).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.9 on 2026-01-17 16:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='is_adult',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stream',
|
||||
name='is_adult',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'),
|
||||
),
|
||||
]
|
||||
|
|
@ -99,6 +99,11 @@ class Stream(models.Model):
|
|||
db_index=True,
|
||||
help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)"
|
||||
)
|
||||
is_adult = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this stream contains adult content"
|
||||
)
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
# Stream statistics fields
|
||||
|
|
@ -301,6 +306,12 @@ class Channel(models.Model):
|
|||
|
||||
user_level = models.IntegerField(default=0)
|
||||
|
||||
is_adult = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this channel contains adult content"
|
||||
)
|
||||
|
||||
auto_created = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this channel was automatically created via M3U auto channel sync"
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
"updated_at",
|
||||
"last_seen",
|
||||
"is_stale",
|
||||
"is_adult",
|
||||
"stream_profile_id",
|
||||
"is_custom",
|
||||
"channel_group",
|
||||
|
|
@ -293,6 +294,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"uuid",
|
||||
"logo_id",
|
||||
"user_level",
|
||||
"is_adult",
|
||||
"auto_created",
|
||||
"auto_created_by",
|
||||
"auto_created_by_name",
|
||||
|
|
@ -330,6 +332,13 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"channel_number", Channel.get_next_available_channel_number()
|
||||
)
|
||||
validated_data["channel_number"] = channel_number
|
||||
|
||||
# Auto-assign Default Group if no channel_group is specified
|
||||
if "channel_group" not in validated_data or validated_data.get("channel_group") is None:
|
||||
from apps.channels.models import ChannelGroup
|
||||
default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group")
|
||||
validated_data["channel_group"] = default_group
|
||||
|
||||
channel = Channel.objects.create(**validated_data)
|
||||
|
||||
# Add streams in the specified order
|
||||
|
|
|
|||
|
|
@ -1874,18 +1874,97 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
|
|||
remux_success = False
|
||||
try:
|
||||
if temp_ts_path and os.path.exists(temp_ts_path):
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", temp_ts_path, "-c", "copy", final_path
|
||||
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
remux_success = os.path.exists(final_path)
|
||||
# Clean up temp file on success
|
||||
# First attempt: Direct TS to MKV remux
|
||||
result = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS
|
||||
"-err_detect", "ignore_err", # Ignore minor stream errors
|
||||
"-i", temp_ts_path,
|
||||
"-map", "0", # Map all streams
|
||||
"-c", "copy",
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
# Check if FFmpeg succeeded (return code 0) and output file is valid
|
||||
if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
# Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps
|
||||
logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV")
|
||||
|
||||
# Clean up partial/failed MKV
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 1: TS → MP4 (MP4 container handles broken timestamps better)
|
||||
temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4"
|
||||
result_mp4 = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-fflags", "+genpts+igndts+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
"-i", temp_ts_path,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
temp_mp4_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0:
|
||||
logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV")
|
||||
|
||||
# Step 2: MP4 → MKV (clean timestamps from MP4)
|
||||
result_mkv = subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", temp_mp4_path,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
final_path
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
|
||||
remux_success = True
|
||||
logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}")
|
||||
else:
|
||||
logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})")
|
||||
|
||||
# Clean up temp MP4
|
||||
try:
|
||||
if os.path.exists(temp_mp4_path):
|
||||
os.remove(temp_mp4_path)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})")
|
||||
|
||||
# Clean up temp TS file only on successful remux
|
||||
if remux_success:
|
||||
try:
|
||||
os.remove(temp_ts_path)
|
||||
logger.debug(f"Cleaned up temp TS file: {temp_ts_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove temp TS file: {e}")
|
||||
else:
|
||||
# Keep TS file for debugging/manual recovery if remux failed
|
||||
logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}")
|
||||
# Clean up any partial MKV
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
logger.debug(f"Cleaned up partial MKV file: {final_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MKV remux failed: {e}")
|
||||
logger.warning(f"MKV remux failed with exception: {e}")
|
||||
# Clean up any partial files on exception
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist final metadata to Recording (status, ended_at, and stream stats if available)
|
||||
try:
|
||||
|
|
@ -2585,6 +2664,7 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
|
|||
"name": name,
|
||||
"tvc_guide_stationid": tvc_guide_stationid,
|
||||
"tvg_id": stream.tvg_id,
|
||||
"is_adult": stream.is_adult,
|
||||
}
|
||||
|
||||
# Only add channel_group_id if the stream has a channel group
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet
|
||||
from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView
|
||||
|
||||
app_name = 'epg'
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ router.register(r'epgdata', EPGDataViewSet, basename='epgdata')
|
|||
urlpatterns = [
|
||||
path('grid/', EPGGridAPIView.as_view(), name='epg_grid'),
|
||||
path('import/', EPGImportAPIView.as_view(), name='epg_import'),
|
||||
path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
|
|
|||
|
|
@ -417,3 +417,89 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
# 6) Current Programs API
|
||||
# ─────────────────────────────
|
||||
class CurrentProgramsAPIView(APIView):
|
||||
"""
|
||||
Lightweight endpoint that returns currently playing programs for specified channel IDs.
|
||||
Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels.
|
||||
"""
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [
|
||||
perm() for perm in permission_classes_by_method[self.request.method]
|
||||
]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Get currently playing programs for specified channels or all channels",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'channel_ids': openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||
description="Array of channel IDs. If null or omitted, returns all channels with current programs.",
|
||||
nullable=True,
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Get channel IDs from request body
|
||||
channel_ids = request.data.get('channel_ids', None)
|
||||
|
||||
# Import Channel model
|
||||
from apps.channels.models import Channel
|
||||
|
||||
# Build query for channels with EPG data
|
||||
query = Channel.objects.filter(epg_data__isnull=False)
|
||||
|
||||
# Filter by specific channel IDs if provided
|
||||
if channel_ids is not None:
|
||||
if not isinstance(channel_ids, list):
|
||||
return Response(
|
||||
{"error": "channel_ids must be an array of integers or null"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
channel_ids = [int(id) for id in channel_ids]
|
||||
except (ValueError, TypeError):
|
||||
return Response(
|
||||
{"error": "channel_ids must contain valid integers"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
query = query.filter(id__in=channel_ids)
|
||||
|
||||
# Get channels with EPG data
|
||||
channels = query.select_related('epg_data')
|
||||
|
||||
# Get current time
|
||||
now = timezone.now()
|
||||
|
||||
# Build list of current programs
|
||||
current_programs = []
|
||||
|
||||
for channel in channels:
|
||||
# Query for current program
|
||||
program = ProgramData.objects.filter(
|
||||
epg=channel.epg_data,
|
||||
start_time__lte=now,
|
||||
end_time__gt=now
|
||||
).first()
|
||||
|
||||
if program:
|
||||
# Serialize program and add channel_id for easy mapping
|
||||
program_data = ProgramDataSerializer(program).data
|
||||
program_data['channel_id'] = channel.id
|
||||
current_programs.append(program_data)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
"channel_group_id": int(group_id),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": stream,
|
||||
"is_adult": int(stream.get("is_adult", 0)) == 1,
|
||||
"is_stale": False,
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +863,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
obj.url != stream_props["url"] or
|
||||
obj.logo_url != stream_props["logo_url"] or
|
||||
obj.tvg_id != stream_props["tvg_id"] or
|
||||
obj.custom_properties != stream_props["custom_properties"]
|
||||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"]
|
||||
)
|
||||
|
||||
if changed:
|
||||
|
|
@ -898,7 +900,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
# Simplified bulk update for better performance
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
|
||||
batch_size=150 # Smaller batch size for XC processing
|
||||
)
|
||||
|
||||
|
|
@ -1011,6 +1013,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
"channel_group_id": int(groups.get(group_title)),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": stream_info["attributes"],
|
||||
"is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
|
||||
"is_stale": False,
|
||||
}
|
||||
|
||||
|
|
@ -1036,7 +1039,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.url != stream_props["url"] or
|
||||
obj.logo_url != stream_props["logo_url"] or
|
||||
obj.tvg_id != stream_props["tvg_id"] or
|
||||
obj.custom_properties != stream_props["custom_properties"]
|
||||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"]
|
||||
)
|
||||
|
||||
# Always update last_seen
|
||||
|
|
@ -1049,6 +1053,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.logo_url = stream_props["logo_url"]
|
||||
obj.tvg_id = stream_props["tvg_id"]
|
||||
obj.custom_properties = stream_props["custom_properties"]
|
||||
obj.is_adult = stream_props["is_adult"]
|
||||
obj.updated_at = timezone.now()
|
||||
|
||||
# Always mark as not stale since we saw it in this refresh
|
||||
|
|
@ -1071,7 +1076,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
# Update all streams in a single bulk operation
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
|
||||
batch_size=200
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -128,13 +128,17 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode()))
|
||||
|
||||
if user is not None:
|
||||
if user.user_level == 0:
|
||||
if user.user_level < 10:
|
||||
user_profile_count = user.channel_profiles.count()
|
||||
|
||||
# If user has ALL profiles or NO profiles, give unrestricted access
|
||||
if user_profile_count == 0:
|
||||
# No profile filtering - user sees all channels based on user_level
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
|
||||
filters = {"user_level__lte": user.user_level}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
else:
|
||||
# User has specific limited profiles assigned
|
||||
filters = {
|
||||
|
|
@ -142,6 +146,9 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
"user_level__lte": user.user_level,
|
||||
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
|
||||
}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
|
|
@ -1258,13 +1265,17 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
|
||||
# Get channels based on user/profile
|
||||
if user is not None:
|
||||
if user.user_level == 0:
|
||||
if user.user_level < 10:
|
||||
user_profile_count = user.channel_profiles.count()
|
||||
|
||||
# If user has ALL profiles or NO profiles, give unrestricted access
|
||||
if user_profile_count == 0:
|
||||
# No profile filtering - user sees all channels based on user_level
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
|
||||
filters = {"user_level__lte": user.user_level}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
else:
|
||||
# User has specific limited profiles assigned
|
||||
filters = {
|
||||
|
|
@ -1272,6 +1283,9 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
"user_level__lte": user.user_level,
|
||||
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
|
||||
}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
|
|
@ -2079,7 +2093,7 @@ def xc_get_live_categories(user):
|
|||
from django.db.models import Min
|
||||
response = []
|
||||
|
||||
if user.user_level == 0:
|
||||
if user.user_level < 10:
|
||||
user_profile_count = user.channel_profiles.count()
|
||||
|
||||
# If user has ALL profiles or NO profiles, give unrestricted access
|
||||
|
|
@ -2116,7 +2130,7 @@ def xc_get_live_categories(user):
|
|||
def xc_get_live_streams(request, user, category_id=None):
|
||||
streams = []
|
||||
|
||||
if user.user_level == 0:
|
||||
if user.user_level < 10:
|
||||
user_profile_count = user.channel_profiles.count()
|
||||
|
||||
# If user has ALL profiles or NO profiles, give unrestricted access
|
||||
|
|
@ -2125,6 +2139,9 @@ def xc_get_live_streams(request, user, category_id=None):
|
|||
filters = {"user_level__lte": user.user_level}
|
||||
if category_id is not None:
|
||||
filters["channel_group__id"] = category_id
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
else:
|
||||
# User has specific limited profiles assigned
|
||||
|
|
@ -2135,6 +2152,9 @@ def xc_get_live_streams(request, user, category_id=None):
|
|||
}
|
||||
if category_id is not None:
|
||||
filters["channel_group__id"] = category_id
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
|
||||
else:
|
||||
if not category_id:
|
||||
|
|
@ -2189,9 +2209,9 @@ def xc_get_live_streams(request, user, category_id=None):
|
|||
),
|
||||
"epg_channel_id": str(channel_num_int),
|
||||
"added": int(channel.created_at.timestamp()),
|
||||
"is_adult": 0,
|
||||
"category_id": str(channel.channel_group.id),
|
||||
"category_ids": [channel.channel_group.id],
|
||||
"is_adult": int(channel.is_adult),
|
||||
"category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id),
|
||||
"category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id],
|
||||
"custom_sid": None,
|
||||
"tv_archive": 0,
|
||||
"direct_source": "",
|
||||
|
|
@ -2214,10 +2234,14 @@ def xc_get_epg(request, user, short=False):
|
|||
# If user has ALL profiles or NO profiles, give unrestricted access
|
||||
if user_profile_count == 0:
|
||||
# No profile filtering - user sees all channels based on user_level
|
||||
channel = Channel.objects.filter(
|
||||
id=channel_id,
|
||||
user_level__lte=user.user_level
|
||||
).first()
|
||||
filters = {
|
||||
"id": channel_id,
|
||||
"user_level__lte": user.user_level
|
||||
}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channel = Channel.objects.filter(**filters).first()
|
||||
else:
|
||||
# User has specific limited profiles assigned
|
||||
filters = {
|
||||
|
|
@ -2226,6 +2250,9 @@ def xc_get_epg(request, user, short=False):
|
|||
"user_level__lte": user.user_level,
|
||||
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
|
||||
}
|
||||
# Hide adult content if user preference is set
|
||||
if (user.custom_properties or {}).get('hide_adult_content', False):
|
||||
filters["is_adult"] = False
|
||||
channel = Channel.objects.filter(**filters).distinct().first()
|
||||
|
||||
if not channel:
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ def refresh_vod_content(account_id):
|
|||
return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds"
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}")
|
||||
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
|
||||
# Send error notification
|
||||
send_m3u_update(account_id, "vod_refresh", 100, status="error",
|
||||
|
|
@ -555,12 +557,19 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
|
||||
# Handle logo assignment for existing movies
|
||||
logo_updated = False
|
||||
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
|
||||
new_logo = existing_logos[logo_url]
|
||||
if movie.logo != new_logo:
|
||||
movie._logo_to_update = new_logo
|
||||
if logo_url and len(logo_url) <= 500:
|
||||
if logo_url in existing_logos:
|
||||
new_logo = existing_logos[logo_url]
|
||||
if movie.logo_id != new_logo.id:
|
||||
movie._logo_to_update = new_logo
|
||||
logo_updated = True
|
||||
elif movie.logo_id:
|
||||
# Logo URL exists but logo creation failed or logo not found
|
||||
# Clear the orphaned logo reference
|
||||
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
|
||||
movie._logo_to_update = None
|
||||
logo_updated = True
|
||||
elif (not logo_url or len(logo_url) > 500) and movie.logo:
|
||||
elif (not logo_url or len(logo_url) > 500) and movie.logo_id:
|
||||
# Clear logo if no logo URL provided or URL is too long
|
||||
movie._logo_to_update = None
|
||||
logo_updated = True
|
||||
|
|
@ -905,12 +914,19 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
|
|||
|
||||
# Handle logo assignment for existing series
|
||||
logo_updated = False
|
||||
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
|
||||
new_logo = existing_logos[logo_url]
|
||||
if series.logo != new_logo:
|
||||
series._logo_to_update = new_logo
|
||||
if logo_url and len(logo_url) <= 500:
|
||||
if logo_url in existing_logos:
|
||||
new_logo = existing_logos[logo_url]
|
||||
if series.logo_id != new_logo.id:
|
||||
series._logo_to_update = new_logo
|
||||
logo_updated = True
|
||||
elif series.logo_id:
|
||||
# Logo URL exists but logo creation failed or logo not found
|
||||
# Clear the orphaned logo reference
|
||||
logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference")
|
||||
series._logo_to_update = None
|
||||
logo_updated = True
|
||||
elif (not logo_url or len(logo_url) > 500) and series.logo:
|
||||
elif (not logo_url or len(logo_url) > 500) and series.logo_id:
|
||||
# Clear logo if no logo URL provided or URL is too long
|
||||
series._logo_to_update = None
|
||||
logo_updated = True
|
||||
|
|
@ -2176,33 +2192,3 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
|
|||
except Exception as e:
|
||||
logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def validate_logo_reference(obj, obj_type="object"):
|
||||
"""
|
||||
Validate that a VOD logo reference exists in the database.
|
||||
If not, set it to None to prevent foreign key constraint violations.
|
||||
|
||||
Args:
|
||||
obj: Object with a logo attribute
|
||||
obj_type: String description of the object type for logging
|
||||
|
||||
Returns:
|
||||
bool: True if logo was valid or None, False if logo was invalid and cleared
|
||||
"""
|
||||
if not hasattr(obj, 'logo') or not obj.logo:
|
||||
return True
|
||||
|
||||
if not obj.logo.pk:
|
||||
# Logo doesn't have a primary key, so it's not saved
|
||||
obj.logo = None
|
||||
return False
|
||||
|
||||
try:
|
||||
# Verify the logo exists in the database
|
||||
VODLogo.objects.get(pk=obj.logo.pk)
|
||||
return True
|
||||
except VODLogo.DoesNotExist:
|
||||
logger.warning(f"VOD Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None")
|
||||
obj.logo = None
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,65 +1,69 @@
|
|||
#!/bin/bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
VERSION=$(python3 -c "import sys; sys.path.append('..'); import version; print(version.__version__)")
|
||||
REGISTRY="dispatcharr" # Registry or private repo to push to
|
||||
IMAGE="dispatcharr" # Image that we're building
|
||||
BRANCH="dev"
|
||||
ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
VERSION=$(python3 -c "import sys; sys.path.append('${ROOT_DIR}'); import version; print(version.__version__)")
|
||||
REGISTRY="dispatcharr" # Registry or private repo to push to
|
||||
IMAGE="dispatcharr" # Image that we're building
|
||||
BRANCH="dev"
|
||||
ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64
|
||||
PUSH=false
|
||||
|
||||
usage() {
|
||||
cat <<- EOF
|
||||
To test locally:
|
||||
./build-dev.sh
|
||||
cat <<-EOF
|
||||
To test locally:
|
||||
./build-dev.sh
|
||||
|
||||
To build and push to registry:
|
||||
./build-dev.sh -p
|
||||
To build and push to registry:
|
||||
./build-dev.sh -p
|
||||
|
||||
To build and push to a private registry:
|
||||
./build-dev.sh -p -r myregistry:5000
|
||||
To build and push to a private registry:
|
||||
./build-dev.sh -p -r myregistry:5000
|
||||
|
||||
To build for -both- x86_64 and arm_64:
|
||||
./build-dev.sh -p -a linux/amd64,linux/arm64
|
||||
To build for -both- x86_64 and arm_64:
|
||||
./build-dev.sh -p -a linux/amd64,linux/arm64
|
||||
|
||||
Do it all:
|
||||
./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64
|
||||
EOF
|
||||
exit 0
|
||||
Do it all:
|
||||
./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse options
|
||||
while getopts "pr:a:b:i:h" opt; do
|
||||
case $opt in
|
||||
r) REGISTRY="$OPTARG" ;;
|
||||
a) ARCH="--platform $OPTARG" ;;
|
||||
b) BRANCH="$OPTARG" ;;
|
||||
i) IMAGE="$OPTARG" ;;
|
||||
p) PUSH=true ;;
|
||||
h) usage ;;
|
||||
\?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
|
||||
esac
|
||||
case $opt in
|
||||
r) REGISTRY="$OPTARG" ;;
|
||||
a) ARCH="$OPTARG" ;;
|
||||
b) BRANCH="$OPTARG" ;;
|
||||
i) IMAGE="$OPTARG" ;;
|
||||
p) PUSH=true ;;
|
||||
h) usage ;;
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
BUILD_ARGS="BRANCH=$BRANCH"
|
||||
|
||||
echo docker build --build-arg $BUILD_ARGS $ARCH -t $IMAGE
|
||||
docker build -f Dockerfile --build-arg $BUILD_ARGS $ARCH -t $IMAGE ..
|
||||
docker tag $IMAGE $IMAGE:$BRANCH
|
||||
docker tag $IMAGE $IMAGE:$VERSION
|
||||
|
||||
if [ -z "$PUSH" ]; then
|
||||
echo "Please run 'docker push -t $IMAGE:dev -t $IMAGE:${VERSION}' when ready"
|
||||
else
|
||||
for TAG in latest "$VERSION" "$BRANCH"; do
|
||||
docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG"
|
||||
docker push -q "$REGISTRY/$IMAGE:$TAG"
|
||||
done
|
||||
echo "Images pushed successfully."
|
||||
ARCH_ARGS=()
|
||||
if [ -n "$ARCH" ]; then
|
||||
ARCH_ARGS=(--platform "$ARCH")
|
||||
fi
|
||||
|
||||
echo docker build --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE"
|
||||
docker build -f "${SCRIPT_DIR}/Dockerfile" --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" "$ROOT_DIR"
|
||||
docker tag "$IMAGE" "$IMAGE":"$BRANCH"
|
||||
docker tag "$IMAGE" "$IMAGE":"$VERSION"
|
||||
|
||||
|
||||
|
||||
|
||||
if [ "$PUSH" = "true" ]; then
|
||||
for TAG in latest "$VERSION" "$BRANCH"; do
|
||||
docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG"
|
||||
docker push -q "$REGISTRY/$IMAGE:$TAG"
|
||||
done
|
||||
echo "Images pushed successfully."
|
||||
else
|
||||
echo "Please run 'docker push $IMAGE:$BRANCH' and 'docker push $IMAGE:${VERSION}' when ready"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -6,17 +6,17 @@ export PGID=${PGID:-1000}
|
|||
|
||||
# Check if group with PGID exists
|
||||
if getent group "$PGID" >/dev/null 2>&1; then
|
||||
# Group exists, check if it's named 'dispatch'
|
||||
# Group exists, check if it's named correctly (should match POSTGRES_USER)
|
||||
existing_group=$(getent group "$PGID" | cut -d: -f1)
|
||||
if [ "$existing_group" != "dispatch" ]; then
|
||||
# Rename the existing group to 'dispatch'
|
||||
groupmod -n "dispatch" "$existing_group"
|
||||
echo "Group $existing_group with GID $PGID renamed to dispatch"
|
||||
if [ "$existing_group" != "$POSTGRES_USER" ]; then
|
||||
# Rename the existing group to match POSTGRES_USER
|
||||
groupmod -n "$POSTGRES_USER" "$existing_group"
|
||||
echo "Group $existing_group with GID $PGID renamed to $POSTGRES_USER"
|
||||
fi
|
||||
else
|
||||
# Group doesn't exist, create it
|
||||
groupadd -g "$PGID" dispatch
|
||||
echo "Group dispatch with GID $PGID created"
|
||||
# Group doesn't exist, create it with same name as POSTGRES_USER
|
||||
groupadd -g "$PGID" "$POSTGRES_USER"
|
||||
echo "Group $POSTGRES_USER with GID $PGID created"
|
||||
fi
|
||||
|
||||
# Create user if it doesn't exist
|
||||
|
|
@ -86,5 +86,5 @@ if getent group video >/dev/null 2>&1; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Run nginx as specified user
|
||||
sed -i "s/user www-data;/user $POSTGRES_USER;/g" /etc/nginx/nginx.conf
|
||||
# Run nginx as specified user (replace any existing user directive on line 1)
|
||||
sed -i "1s/^user .*/user $POSTGRES_USER;/" /etc/nginx/nginx.conf
|
||||
|
|
|
|||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
|
|
@ -3720,9 +3720,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clamp": {
|
||||
|
|
@ -4404,9 +4404,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
|
|
@ -4426,12 +4426,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// frontend/src/App.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Route,
|
||||
|
|
@ -40,18 +39,25 @@ const defaultRoute = '/channels';
|
|||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isInitialized = useAuthStore((s) => s.isInitialized);
|
||||
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const initData = useAuthStore((s) => s.initData);
|
||||
const initializeAuth = useAuthStore((s) => s.initializeAuth);
|
||||
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
|
||||
|
||||
const authCheckStarted = useRef(false);
|
||||
const superuserCheckStarted = useRef(false);
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
// Check if a superuser exists on first load.
|
||||
useEffect(() => {
|
||||
if (superuserCheckStarted.current) return;
|
||||
superuserCheckStarted.current = true;
|
||||
|
||||
async function checkSuperuser() {
|
||||
try {
|
||||
const response = await API.fetchSuperUser();
|
||||
|
|
@ -69,10 +75,13 @@ const App = () => {
|
|||
}
|
||||
}
|
||||
checkSuperuser();
|
||||
}, []);
|
||||
}, [setSuperuserExists]);
|
||||
|
||||
// Authentication check
|
||||
useEffect(() => {
|
||||
if (authCheckStarted.current) return;
|
||||
authCheckStarted.current = true;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const loggedIn = await initializeAuth();
|
||||
|
|
@ -105,14 +114,15 @@ const App = () => {
|
|||
height: 0,
|
||||
}}
|
||||
navbar={{
|
||||
width: isAuthenticated
|
||||
? open
|
||||
? drawerWidth
|
||||
: miniDrawerWidth
|
||||
: 0,
|
||||
width:
|
||||
isAuthenticated && isInitialized
|
||||
? open
|
||||
? drawerWidth
|
||||
: miniDrawerWidth
|
||||
: 0,
|
||||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
{isAuthenticated && isInitialized && (
|
||||
<Sidebar
|
||||
drawerWidth={drawerWidth}
|
||||
miniDrawerWidth={miniDrawerWidth}
|
||||
|
|
@ -134,7 +144,7 @@ const App = () => {
|
|||
>
|
||||
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
|
||||
<Routes>
|
||||
{isAuthenticated ? (
|
||||
{isAuthenticated && isInitialized ? (
|
||||
<>
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/sources" element={<ContentSources />} />
|
||||
|
|
@ -154,7 +164,11 @@ const App = () => {
|
|||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={isAuthenticated ? defaultRoute : '/login'}
|
||||
to={
|
||||
isAuthenticated && isInitialized
|
||||
? defaultRoute
|
||||
: '/login'
|
||||
}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -755,6 +755,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
// Refresh the channels table to show new channels
|
||||
try {
|
||||
await API.requeryChannels();
|
||||
await API.requeryStreams();
|
||||
await useChannelsStore.getState().fetchChannels();
|
||||
await fetchChannelProfiles();
|
||||
console.log('Channels refreshed after bulk creation');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import useStreamProfilesStore from './store/streamProfiles';
|
|||
import useSettingsStore from './store/settings';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsTableStore from './store/channelsTable';
|
||||
import useStreamsTableStore from './store/streamsTable';
|
||||
import useUsersStore from './store/users';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
|
|
@ -197,6 +198,31 @@ export default class API {
|
|||
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Handle invalid page error by resetting to page 1 and retrying
|
||||
if (e.body?.detail === 'Invalid page.') {
|
||||
const currentPagination = useChannelsTableStore.getState().pagination;
|
||||
|
||||
// Only retry if we're not already on page 1
|
||||
if (currentPagination.pageIndex > 0) {
|
||||
// Reset to page 1
|
||||
useChannelsTableStore.getState().setPagination({
|
||||
...currentPagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
|
||||
// Update params to page 1 and retry
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.set('page', '1');
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/?${newParams.toString()}`
|
||||
);
|
||||
|
||||
useChannelsTableStore.getState().queryChannels(response, newParams);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
errorNotification('Failed to fetch channels', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -217,6 +243,39 @@ export default class API {
|
|||
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Handle invalid page error by resetting to page 1 and retrying
|
||||
if (e.body?.detail === 'Invalid page.') {
|
||||
const currentPagination = useChannelsTableStore.getState().pagination;
|
||||
|
||||
// Only retry if we're not already on page 1
|
||||
if (currentPagination.pageIndex > 0) {
|
||||
// Reset to page 1
|
||||
useChannelsTableStore.getState().setPagination({
|
||||
...currentPagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
|
||||
// Update params to page 1 and retry
|
||||
const newParams = new URLSearchParams(API.lastQueryParams);
|
||||
newParams.set('page', '1');
|
||||
API.lastQueryParams = newParams;
|
||||
|
||||
const [response, ids] = await Promise.all([
|
||||
request(
|
||||
`${host}/api/channels/channels/?${newParams.toString()}`
|
||||
),
|
||||
API.getAllChannelIds(newParams),
|
||||
]);
|
||||
|
||||
useChannelsTableStore
|
||||
.getState()
|
||||
.queryChannels(response, newParams);
|
||||
useChannelsTableStore.getState().setAllQueryIds(ids);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
errorNotification('Failed to fetch channels', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -380,6 +439,7 @@ export default class API {
|
|||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels([id]);
|
||||
await API.requeryStreams();
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete channel', e);
|
||||
}
|
||||
|
|
@ -394,6 +454,7 @@ export default class API {
|
|||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels(channel_ids);
|
||||
await API.requeryStreams();
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete channels', e);
|
||||
}
|
||||
|
|
@ -447,6 +508,9 @@ export default class API {
|
|||
);
|
||||
|
||||
useChannelsStore.getState().updateChannel(response);
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'streams')) {
|
||||
await API.requeryStreams();
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update channel', e);
|
||||
|
|
@ -504,6 +568,24 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async reorderChannel(channelId, insertAfterId) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/${channelId}/reorder/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
insert_after_id: insertAfterId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to reorder channel', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async setChannelEPG(channelId, epgDataId) {
|
||||
try {
|
||||
const response = await request(
|
||||
|
|
@ -630,6 +712,7 @@ export default class API {
|
|||
useChannelsStore.getState().addChannel(response);
|
||||
}
|
||||
|
||||
await API.requeryStreams();
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create channel', e);
|
||||
|
|
@ -705,6 +788,50 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async queryStreamsTable(params) {
|
||||
try {
|
||||
API.lastStreamQueryParams = params;
|
||||
useStreamsTableStore.getState().setLastQueryParams(params);
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/channels/streams/?${params.toString()}`
|
||||
);
|
||||
|
||||
useStreamsTableStore.getState().queryStreams(response, params);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch streams', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async requeryStreams() {
|
||||
const params =
|
||||
useStreamsTableStore.getState().lastQueryParams ||
|
||||
API.lastStreamQueryParams;
|
||||
if (!params) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [response, ids] = await Promise.all([
|
||||
request(
|
||||
`${host}/api/channels/streams/?${params.toString()}`
|
||||
),
|
||||
API.getAllStreamIds(params),
|
||||
]);
|
||||
|
||||
useStreamsTableStore
|
||||
.getState()
|
||||
.queryStreams(response, params);
|
||||
useStreamsTableStore.getState().setAllQueryIds(ids);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch streams', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllStreamIds(params) {
|
||||
try {
|
||||
const response = await request(
|
||||
|
|
@ -727,6 +854,20 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getStreamFilterOptions(params) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/streams/filter-options/?${params.toString()}`
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve filter options', e);
|
||||
// Return safe defaults to prevent crashes during container startup
|
||||
return { groups: [], m3u_accounts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
static async addStream(values) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/streams/`, {
|
||||
|
|
@ -738,6 +879,7 @@ export default class API {
|
|||
useStreamsStore.getState().addStream(response);
|
||||
}
|
||||
|
||||
await API.requeryStreams();
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to add stream', e);
|
||||
|
|
@ -756,6 +898,7 @@ export default class API {
|
|||
useStreamsStore.getState().updateStream(response);
|
||||
}
|
||||
|
||||
await API.requeryStreams();
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update stream', e);
|
||||
|
|
@ -769,6 +912,7 @@ export default class API {
|
|||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams([id]);
|
||||
await API.requeryStreams();
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete stream', e);
|
||||
}
|
||||
|
|
@ -782,6 +926,7 @@ export default class API {
|
|||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams(ids);
|
||||
await API.requeryStreams();
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete streams', e);
|
||||
}
|
||||
|
|
@ -1032,6 +1177,23 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getCurrentPrograms(channelIds = null) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/current-programs/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { channel_ids: channelIds },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error('Failed to retrieve current programs', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Notice there's a duplicated "refreshPlaylist" method above;
|
||||
// you might want to rename or remove one if it's not needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { copyToClipboard } from '../utils';
|
||||
import {
|
||||
|
|
@ -33,8 +33,7 @@ import logo from '../images/logo.png';
|
|||
import useChannelsStore from '../store/channels';
|
||||
import './sidebar.css';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useAuthStore from '../store/auth'; // Add this import
|
||||
import API from '../api';
|
||||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UserForm from './forms/User';
|
||||
|
||||
|
|
@ -75,16 +74,13 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const environment = useSettingsStore((s) => s.environment);
|
||||
const appVersion = useSettingsStore((s) => s.version);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const publicIPRef = useRef(null);
|
||||
|
||||
const [appVersion, setAppVersion] = useState({
|
||||
version: '',
|
||||
timestamp: null,
|
||||
});
|
||||
const [userFormOpen, setUserFormOpen] = useState(false);
|
||||
|
||||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
|
@ -144,36 +140,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
},
|
||||
];
|
||||
|
||||
// Fetch environment settings including version on component mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchEnvironment = async () => {
|
||||
API.getEnvironmentSettings();
|
||||
};
|
||||
|
||||
fetchEnvironment();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Fetch version information on component mount (regardless of authentication)
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const versionData = await API.getVersion();
|
||||
setAppVersion({
|
||||
version: versionData.version || '',
|
||||
timestamp: versionData.timestamp || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version information:', error);
|
||||
// Keep using default values from useState initialization
|
||||
}
|
||||
};
|
||||
|
||||
fetchVersion();
|
||||
}, []);
|
||||
// Environment settings and version are loaded by the settings store during initData()
|
||||
// No need to fetch them again here - just use the store values
|
||||
|
||||
const copyPublicIP = async () => {
|
||||
const success = await copyToClipboard(environment.public_ip);
|
||||
|
|
|
|||
|
|
@ -236,7 +236,6 @@ export default function BackupManager() {
|
|||
// Read user's preferences from settings
|
||||
const [timeFormat] = useLocalStorage('time-format', '12h');
|
||||
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
|
||||
const is12Hour = timeFormat === '12h';
|
||||
|
||||
|
|
@ -309,10 +308,10 @@ export default function BackupManager() {
|
|||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: tableSize === 'compact' ? 75 : 100,
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
[tableSize]
|
||||
[]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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 {
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import React from 'react';
|
||||
import {
|
||||
|
|
@ -39,7 +42,8 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
|||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [timeformat, dateformat] = useDateTimeFormat();
|
||||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const channel = channels?.[recording.channel];
|
||||
|
||||
|
|
@ -52,7 +56,11 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
|||
|
||||
// Poster or channel logo
|
||||
const posterUrl = getPosterUrl(
|
||||
customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode);
|
||||
customProps.poster_logo_id,
|
||||
customProps,
|
||||
channel?.logo?.cache_url,
|
||||
env_mode
|
||||
);
|
||||
|
||||
const start = toUserTime(recording.start_time);
|
||||
const end = toUserTime(recording.end_time);
|
||||
|
|
@ -161,44 +169,49 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
|||
} 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'
|
||||
}
|
||||
>
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchRecording();
|
||||
handleWatchLive();
|
||||
}}
|
||||
disabled={
|
||||
customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)
|
||||
>
|
||||
Watch Live
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchRecording = () => {
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
customProps.file_url || customProps.output_file_url
|
||||
? 'Watch recording'
|
||||
: 'Recording playback not available yet'
|
||||
}
|
||||
>
|
||||
Watch
|
||||
</Button>
|
||||
</Tooltip>;
|
||||
}
|
||||
<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
|
||||
|
|
@ -310,7 +323,8 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
|||
{isSeriesGroup ? 'Next recording' : 'Time'}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
|
|
@ -419,4 +433,4 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default RecordingCard;
|
||||
export default RecordingCard;
|
||||
|
|
|
|||
|
|
@ -11,21 +11,30 @@ import {
|
|||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
Gauge,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
Radio,
|
||||
SquareX,
|
||||
Timer,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
|
||||
import {
|
||||
toFriendlyDuration,
|
||||
useDateTimeFormat,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
|
||||
import { TableHelper } from '../../helpers/index.jsx';
|
||||
import logo from '../../images/logo.png';
|
||||
|
|
@ -45,6 +54,7 @@ import {
|
|||
getStreamsByIds,
|
||||
switchStream,
|
||||
} from '../../utils/cards/StreamConnectionCardUtils.js';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
|
||||
// Create a separate component for each channel card to properly handle the hook
|
||||
const StreamConnectionCard = ({
|
||||
|
|
@ -54,6 +64,8 @@ const StreamConnectionCard = ({
|
|||
stopChannel,
|
||||
logos,
|
||||
channelsByUUID,
|
||||
channels,
|
||||
currentProgram,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [availableStreams, setAvailableStreams] = useState([]);
|
||||
|
|
@ -62,16 +74,21 @@ const StreamConnectionCard = ({
|
|||
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
|
||||
const [data, setData] = useState([]);
|
||||
const [previewedStream, setPreviewedStream] = useState(null);
|
||||
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
// Get M3U account data from the playlists store
|
||||
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
|
||||
// Get settings for speed threshold
|
||||
// Get settings for speed threshold and environment mode
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const env_mode =
|
||||
useSettingsStore((s) => s.environment?.env_mode) || 'production';
|
||||
// Get video preview function
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
|
||||
// Get Date-format from localStorage
|
||||
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
||||
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
// Get user's date/time format preferences
|
||||
const { fullDateTimeFormat } = useDateTimeFormat();
|
||||
|
||||
// Create a map of M3U account IDs to names for quick lookup
|
||||
const m3uAccountsMap = useMemo(() => {
|
||||
|
|
@ -254,12 +271,20 @@ const StreamConnectionCard = ({
|
|||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ip_address',
|
||||
size: 150,
|
||||
cell: ({ cell }) => (
|
||||
<Tooltip label={cell.getValue()}>
|
||||
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
|
||||
{cell.getValue()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
// Updated Connected column with tooltip
|
||||
{
|
||||
id: 'connected',
|
||||
header: 'Connected',
|
||||
accessorFn: connectedAccessor(dateFormat),
|
||||
accessorFn: connectedAccessor(fullDateTimeFormat),
|
||||
cell: ({ cell }) => (
|
||||
<Tooltip
|
||||
label={
|
||||
|
|
@ -296,10 +321,10 @@ const StreamConnectionCard = ({
|
|||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: tableSize == 'compact' ? 75 : 100,
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
[]
|
||||
[fullDateTimeFormat]
|
||||
);
|
||||
|
||||
const channelClientsTable = useTable({
|
||||
|
|
@ -392,6 +417,23 @@ const StreamConnectionCard = ({
|
|||
// Create select options for available streams
|
||||
const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap);
|
||||
|
||||
// Handle preview channel button click
|
||||
const handlePreviewChannel = () => {
|
||||
const channelDbId = channelsByUUID[channel.channel_id];
|
||||
if (!channelDbId) return;
|
||||
|
||||
const actualChannel = channels[channelDbId];
|
||||
if (!actualChannel?.uuid) return;
|
||||
|
||||
const uri = `/proxy/ts/stream/${actualChannel.uuid}`;
|
||||
let url = `${window.location.protocol}//${window.location.host}${uri}`;
|
||||
if (env_mode === 'dev') {
|
||||
url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
|
||||
}
|
||||
|
||||
showVideo(url);
|
||||
};
|
||||
|
||||
if (location.pathname !== '/stats') {
|
||||
return <></>;
|
||||
}
|
||||
|
|
@ -416,14 +458,14 @@ const StreamConnectionCard = ({
|
|||
w={'100%'}
|
||||
>
|
||||
<Stack pos="relative">
|
||||
<Group justify="space-between">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
w={100}
|
||||
h={50}
|
||||
w={140}
|
||||
h={70}
|
||||
display="flex"
|
||||
>
|
||||
<img
|
||||
|
|
@ -437,7 +479,7 @@ const StreamConnectionCard = ({
|
|||
/>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Group mt={10}>
|
||||
<Box>
|
||||
<Tooltip label={getStartDate(uptime)}>
|
||||
<Center>
|
||||
|
|
@ -460,49 +502,157 @@ const StreamConnectionCard = ({
|
|||
</Group>
|
||||
</Group>
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Text fw={500}>{channelName}</Text>
|
||||
</Group>
|
||||
|
||||
{/* Stream Profile on right - absolutely positioned */}
|
||||
<Box pos="absolute" top={65} right={16} style={{ zIndex: 1 }}>
|
||||
<Tooltip label="Active Stream Profile">
|
||||
<Group gap={5}>
|
||||
<Video size="18" />
|
||||
{streamProfileName}
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Display M3U profile information */}
|
||||
<Flex justify="flex-end" align="center" mt={-8}>
|
||||
{/* M3U Profile on right - absolutely positioned */}
|
||||
<Box pos="absolute" top={95} right={16} style={{ zIndex: 1 }}>
|
||||
<Group gap={5}>
|
||||
<HardDriveUpload size="18" />
|
||||
<Tooltip label="Current M3U Profile">
|
||||
<Text size="xs">{m3uProfileName}</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Add stream selection dropdown */}
|
||||
{availableStreams.length > 0 && (
|
||||
<Tooltip label="Switch to another stream source">
|
||||
<Select
|
||||
{/* Channel Name on left */}
|
||||
<Box mt={4}>
|
||||
<Text fw={500}>{channelName}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Display current program on its own line */}
|
||||
{currentProgram && (
|
||||
<Group gap={5} mt={-9} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
Now Playing:
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentProgram.title}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
label="Active Stream"
|
||||
placeholder={
|
||||
isLoadingStreams ? 'Loading streams...' : 'Select stream'
|
||||
variant="subtle"
|
||||
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{isProgramDescExpanded ? (
|
||||
<ChevronDown size="14" />
|
||||
) : (
|
||||
<ChevronRight size="14" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Expandable program description */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.description && (
|
||||
<Box mt={4} ml={24}>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
|
||||
{currentProgram.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Program progress bar */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.start_time &&
|
||||
currentProgram.end_time &&
|
||||
(() => {
|
||||
const now = new Date();
|
||||
const startTime = new Date(currentProgram.start_time);
|
||||
const endTime = new Date(currentProgram.end_time);
|
||||
const totalDuration = (endTime - startTime) / 1000; // in seconds
|
||||
const elapsed = (now - startTime) / 1000; // in seconds
|
||||
const remaining = (endTime - now) / 1000; // in seconds
|
||||
const percentage = Math.min(
|
||||
100,
|
||||
Math.max(0, (elapsed / totalDuration) * 100)
|
||||
);
|
||||
|
||||
const formatProgramTime = (seconds) => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
const hours = Math.floor(absSeconds / 3600);
|
||||
const minutes = Math.floor((absSeconds % 3600) / 60);
|
||||
const secs = Math.floor(absSeconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
data={streamOptions}
|
||||
value={activeStreamId || channel.stream_id?.toString() || null}
|
||||
onChange={handleStreamChange}
|
||||
disabled={isLoadingStreams}
|
||||
mt={8}
|
||||
/>
|
||||
</Tooltip>
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mt={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(elapsed)} elapsed
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(remaining)} remaining
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
color="#3BA882"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Add stream selection dropdown and preview button */}
|
||||
{availableStreams.length > 0 && (
|
||||
<Box mt={-10}>
|
||||
<Group align="flex-end" gap="xs">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Tooltip label="Switch to another stream source">
|
||||
<Select
|
||||
size="xs"
|
||||
label="Active Stream"
|
||||
placeholder={
|
||||
isLoadingStreams ? 'Loading streams...' : 'Select stream'
|
||||
}
|
||||
data={streamOptions}
|
||||
value={
|
||||
activeStreamId || channel.stream_id?.toString() || null
|
||||
}
|
||||
onChange={handleStreamChange}
|
||||
disabled={isLoadingStreams}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{channel.name && (
|
||||
<Tooltip label="Preview Channel">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={handlePreviewChannel}
|
||||
style={{ marginBottom: 1 }}
|
||||
>
|
||||
<CirclePlay size="20" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Add stream information badges */}
|
||||
<Group gap="xs" mt="xs">
|
||||
<Group gap="xs" mt="5">
|
||||
{channel.resolution && (
|
||||
<Tooltip label="Video resolution">
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,32 @@
|
|||
// Format duration for content length
|
||||
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import logo from '../../images/logo.png';
|
||||
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Progress, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { convertToSec, fromNow, toFriendlyDuration } from '../../utils/dateTimeUtils.js';
|
||||
import { ChevronDown, HardDriveUpload, SquareX, Timer, Video } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
convertToSec,
|
||||
fromNow,
|
||||
toFriendlyDuration,
|
||||
useDateTimeFormat,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import {
|
||||
ChevronDown,
|
||||
HardDriveUpload,
|
||||
SquareX,
|
||||
Timer,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
calculateConnectionDuration,
|
||||
calculateConnectionStartTime,
|
||||
|
|
@ -28,19 +50,18 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
|
|||
bdrs={6}
|
||||
bd={'1px solid rgba(255, 255, 255, 0.08)'}
|
||||
>
|
||||
{connection.user_agent &&
|
||||
connection.user_agent !== 'Unknown' && (
|
||||
<Group gap={8} align="flex-start">
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
User Agent:
|
||||
</Text>
|
||||
<Text size="xs" ff={'monospace'} flex={1}>
|
||||
{connection.user_agent.length > 100
|
||||
? `${connection.user_agent.substring(0, 100)}...`
|
||||
: connection.user_agent}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{connection.user_agent && connection.user_agent !== 'Unknown' && (
|
||||
<Group gap={8} align="flex-start">
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
User Agent:
|
||||
</Text>
|
||||
<Text size="xs" ff={'monospace'} flex={1}>
|
||||
{connection.user_agent.length > 100
|
||||
? `${connection.user_agent.substring(0, 100)}...`
|
||||
: connection.user_agent}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={8}>
|
||||
<Text size="xs" fw={500} c="dimmed" miw={80}>
|
||||
|
|
@ -86,9 +107,7 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
|
|||
{' '}
|
||||
({Math.round(connection.last_seek_byte / (1024 * 1024))}
|
||||
MB /{' '}
|
||||
{Math.round(
|
||||
connection.total_content_size / (1024 * 1024)
|
||||
)}
|
||||
{Math.round(connection.total_content_size / (1024 * 1024))}
|
||||
MB)
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -120,12 +139,11 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
|
|||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a VOD Card component similar to ChannelCard
|
||||
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
||||
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
||||
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
|
||||
const { fullDateTimeFormat } = useDateTimeFormat();
|
||||
const [isClientExpanded, setIsClientExpanded] = useState(false);
|
||||
const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
|
||||
|
||||
|
|
@ -197,9 +215,9 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
// Get connection start time for tooltip
|
||||
const getConnectionStartTime = useCallback(
|
||||
(connection) => {
|
||||
return calculateConnectionStartTime(connection, dateFormat);
|
||||
return calculateConnectionStartTime(connection, fullDateTimeFormat);
|
||||
},
|
||||
[dateFormat]
|
||||
[fullDateTimeFormat]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -211,14 +229,16 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color='#FFF'
|
||||
color="#FFF"
|
||||
maw={700}
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack pos='relative' >
|
||||
<Stack pos="relative">
|
||||
{/* Header with poster and basic info */}
|
||||
<Group justify="space-between">
|
||||
<Box h={100} display='flex'
|
||||
<Box
|
||||
h={100}
|
||||
display="flex"
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -338,7 +358,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
{connection &&
|
||||
metadata.duration_secs &&
|
||||
(() => {
|
||||
const { totalTime, currentTime, percentage} = getProgressInfo();
|
||||
const { totalTime, currentTime, percentage } = getProgressInfo();
|
||||
return totalTime > 0 ? (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
|
|
@ -346,8 +366,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
Progress
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTime(currentTime)} /{' '}
|
||||
{formatTime(totalTime)}
|
||||
{formatTime(currentTime)} / {formatTime(totalTime)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
|
|
@ -410,7 +429,8 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
{isClientExpanded && (
|
||||
<ClientDetails
|
||||
connection={connection}
|
||||
connectionStartTime={getConnectionStartTime(connection)} />
|
||||
connectionStartTime={getConnectionStartTime(connection)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
|
@ -419,4 +439,4 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default VodConnectionCard;
|
||||
export default VodConnectionCard;
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ import {
|
|||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
NativeSelect,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Center,
|
||||
Grid,
|
||||
Flex,
|
||||
Select,
|
||||
Divider,
|
||||
|
|
@ -33,8 +31,8 @@ import {
|
|||
ScrollArea,
|
||||
Tooltip,
|
||||
NumberInput,
|
||||
Image,
|
||||
UnstyledButton,
|
||||
Switch,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react';
|
||||
|
|
@ -318,6 +316,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
epg_data_id: channel?.epg_data_id ?? '',
|
||||
logo_id: channel?.logo_id ? `${channel.logo_id}` : '',
|
||||
user_level: `${channel?.user_level ?? '0'}`,
|
||||
is_adult: channel?.is_adult ?? false,
|
||||
}),
|
||||
[channel, channelGroups]
|
||||
);
|
||||
|
|
@ -811,6 +810,18 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
>
|
||||
Upload or Create Logo
|
||||
</Button>
|
||||
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Mature Content"
|
||||
checked={watch('is_adult')}
|
||||
onChange={(event) =>
|
||||
setValue('is_adult', event.currentTarget.checked)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
logo: '(no change)',
|
||||
stream_profile_id: '-1',
|
||||
user_level: '-1',
|
||||
is_adult: '-1',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -153,6 +154,13 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
changes.push(`• User Level: ${userLevelLabel}`);
|
||||
}
|
||||
|
||||
// Check mature content flag
|
||||
if (values.is_adult && values.is_adult !== '-1') {
|
||||
changes.push(
|
||||
`• Mature Content: ${values.is_adult === 'true' ? 'Yes' : 'No'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check dummy EPG
|
||||
if (selectedDummyEpgId) {
|
||||
if (selectedDummyEpgId === 'clear') {
|
||||
|
|
@ -223,6 +231,14 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
delete values.user_level;
|
||||
}
|
||||
|
||||
if (values.is_adult === '-1') {
|
||||
delete values.is_adult;
|
||||
} else if (values.is_adult === 'true') {
|
||||
values.is_adult = true;
|
||||
} else if (values.is_adult === 'false') {
|
||||
values.is_adult = false;
|
||||
}
|
||||
|
||||
// Remove the channel_group field from form values as we use channel_group_id
|
||||
delete values.channel_group;
|
||||
|
||||
|
|
@ -931,6 +947,18 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
})
|
||||
)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
size="xs"
|
||||
label="Mature Content"
|
||||
{...form.getInputProps('is_adult')}
|
||||
key={form.key('is_adult')}
|
||||
data={[
|
||||
{ value: '-1', label: '(no change)' },
|
||||
{ value: 'true', label: 'Yes' },
|
||||
{ value: 'false', label: 'No' },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import API from '../../api';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import {
|
||||
Paper,
|
||||
Title,
|
||||
|
|
@ -25,13 +25,14 @@ const LoginForm = () => {
|
|||
const logout = useAuthStore((s) => s.logout);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const initData = useAuthStore((s) => s.initData);
|
||||
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
||||
const storedVersion = useSettingsStore((s) => s.version);
|
||||
|
||||
const navigate = useNavigate(); // Hook to navigate to other routes
|
||||
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [savePassword, setSavePassword] = useState(false);
|
||||
const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false);
|
||||
const [version, setVersion] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Simple base64 encoding/decoding for localStorage
|
||||
|
|
@ -55,11 +56,9 @@ const LoginForm = () => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch version info
|
||||
API.getVersion().then((data) => {
|
||||
setVersion(data?.version);
|
||||
});
|
||||
}, []);
|
||||
// Fetch version info using the settings store (will skip if already loaded)
|
||||
fetchVersion();
|
||||
}, [fetchVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved username if it exists
|
||||
|
|
@ -234,8 +233,8 @@ const LoginForm = () => {
|
|||
lineHeight: '1.2',
|
||||
}}
|
||||
>
|
||||
⚠ Password will be stored locally without encryption. Only
|
||||
use on trusted devices.
|
||||
⚠ Password will be stored locally without encryption. Only use
|
||||
on trusted devices.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -252,7 +251,7 @@ const LoginForm = () => {
|
|||
</Stack>
|
||||
</form>
|
||||
|
||||
{version && (
|
||||
{storedVersion.version && (
|
||||
<Text
|
||||
size="xs"
|
||||
color="dimmed"
|
||||
|
|
@ -262,7 +261,7 @@ const LoginForm = () => {
|
|||
right: 30,
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
v{storedVersion.version}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
|
|
@ -19,22 +32,23 @@ import {
|
|||
} from '../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
|
||||
const RecordingDetailsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
recording,
|
||||
channel,
|
||||
posterUrl,
|
||||
onWatchLive,
|
||||
onWatchRecording,
|
||||
env_mode,
|
||||
onEdit,
|
||||
}) => {
|
||||
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 { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const safeRecording = recording || {};
|
||||
const customProps = safeRecording.custom_properties || {};
|
||||
|
|
@ -61,7 +75,13 @@ const RecordingDetailsModal = ({
|
|||
safeRecording._group_count && safeRecording._group_count > 1
|
||||
);
|
||||
const upcomingEpisodes = React.useMemo(() => {
|
||||
return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
|
||||
return getUpcomingEpisodes(
|
||||
isSeriesGroup,
|
||||
allRecordings,
|
||||
program,
|
||||
toUserTime,
|
||||
userNow
|
||||
);
|
||||
}, [
|
||||
allRecordings,
|
||||
isSeriesGroup,
|
||||
|
|
@ -79,31 +99,32 @@ const RecordingDetailsModal = ({
|
|||
|
||||
if (now.isAfter(s) && now.isBefore(e)) {
|
||||
if (!channelMap[rec.channel]) return;
|
||||
useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
|
||||
useVideoStore
|
||||
.getState()
|
||||
.showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnWatchRecording = () => {
|
||||
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode)
|
||||
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode);
|
||||
if (!fileUrl) return;
|
||||
|
||||
useVideoStore.getState().showVideo(fileUrl, 'vod', {
|
||||
name:
|
||||
childRec.custom_properties?.program?.title || 'Recording',
|
||||
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)
|
||||
await runComSkip(recording);
|
||||
notifications.show({
|
||||
title: 'Removing commercials',
|
||||
message: 'Queued comskip for this recording',
|
||||
|
|
@ -113,7 +134,7 @@ const RecordingDetailsModal = ({
|
|||
} catch (error) {
|
||||
console.error('Failed to run comskip', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
|
|
@ -147,7 +168,7 @@ const RecordingDetailsModal = ({
|
|||
const handleOnMainCardClick = () => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
|
|
@ -183,7 +204,8 @@ const RecordingDetailsModal = ({
|
|||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
|
|
@ -197,142 +219,153 @@ const RecordingDetailsModal = ({
|
|||
};
|
||||
|
||||
const WatchLive = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>;
|
||||
}
|
||||
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>;
|
||||
}
|
||||
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>;
|
||||
}
|
||||
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}
|
||||
return (
|
||||
<Stack gap={10}>
|
||||
{upcomingEpisodes.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No upcoming episodes found
|
||||
</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>
|
||||
{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>
|
||||
</Flex>;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
|
|
@ -359,4 +392,4 @@ const RecordingDetailsModal = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default RecordingDetailsModal;
|
||||
export default RecordingDetailsModal;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,19 @@ 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 {
|
||||
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 {
|
||||
|
|
@ -28,7 +40,8 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [timeformat, dateformat] = useDateTimeFormat();
|
||||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
|
@ -198,73 +211,70 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
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 (
|
||||
<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>
|
||||
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>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>;
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -371,11 +381,13 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
|||
<Text size="sm" c="dimmed">
|
||||
No future airings currently scheduled.
|
||||
</Text>
|
||||
) : <UpcomingList />}
|
||||
) : (
|
||||
<UpcomingList />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurringRuleModal;
|
||||
export default RecurringRuleModal;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
import API from '../../api';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import logo from '../../assets/logo.png';
|
||||
|
||||
function SuperuserForm() {
|
||||
|
|
@ -22,15 +23,14 @@ function SuperuserForm() {
|
|||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [version, setVersion] = useState(null);
|
||||
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
|
||||
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
||||
const storedVersion = useSettingsStore((s) => s.version);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch version info
|
||||
API.getVersion().then((data) => {
|
||||
setVersion(data?.version);
|
||||
});
|
||||
}, []);
|
||||
// Fetch version info using the settings store (will skip if already loaded)
|
||||
fetchVersion();
|
||||
}, [fetchVersion]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
|
|
@ -120,7 +120,7 @@ function SuperuserForm() {
|
|||
</Stack>
|
||||
</form>
|
||||
|
||||
{version && (
|
||||
{storedVersion.version && (
|
||||
<Text
|
||||
size="xs"
|
||||
color="dimmed"
|
||||
|
|
@ -130,7 +130,7 @@ function SuperuserForm() {
|
|||
right: 30,
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
v{storedVersion.version}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
Stack,
|
||||
MultiSelect,
|
||||
ActionIcon,
|
||||
Switch,
|
||||
Box,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { RotateCcwKey, X } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
|
|
@ -38,6 +41,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
password: '',
|
||||
xc_password: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
},
|
||||
|
||||
validate: (values) => ({
|
||||
|
|
@ -80,6 +84,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
customProps.xc_password = values.xc_password || '';
|
||||
delete values.xc_password;
|
||||
|
||||
// Save hide_adult_content in custom_properties
|
||||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
||||
values.custom_properties = customProps;
|
||||
|
||||
// If 'All' is included, clear this and we assume access to all channels
|
||||
|
|
@ -125,6 +133,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
});
|
||||
|
||||
if (customProps.xc_password) {
|
||||
|
|
@ -242,6 +251,24 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPermissions && (
|
||||
<Box>
|
||||
<Tooltip
|
||||
label="Hide channels marked as mature content (admin users not affected)"
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Switch
|
||||
label="Hide Mature Content"
|
||||
{...form.getInputProps('hide_adult_content', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import useSettingsStore from '../../../store/settings.jsx';
|
||||
import useLocalStorage from '../../../hooks/useLocalStorage.jsx';
|
||||
import useTablePreferences from '../../../hooks/useTablePreferences.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 { Select, Switch, Stack } 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(
|
||||
|
|
@ -20,6 +20,10 @@ const UiSettingsForm = React.memo(() => {
|
|||
getDefaultTimeZone()
|
||||
);
|
||||
|
||||
// Use shared table preferences hook
|
||||
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
|
||||
useTablePreferences();
|
||||
|
||||
const timeZoneOptions = useMemo(
|
||||
() => buildTimeZoneOptions(timeZone),
|
||||
[timeZone]
|
||||
|
|
@ -74,11 +78,14 @@ const UiSettingsForm = React.memo(() => {
|
|||
persistTimeZoneSetting(value);
|
||||
}
|
||||
break;
|
||||
case 'header-pinned':
|
||||
setHeaderPinned(value);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Table Size"
|
||||
value={tableSize}
|
||||
|
|
@ -98,6 +105,14 @@ const UiSettingsForm = React.memo(() => {
|
|||
},
|
||||
]}
|
||||
/>
|
||||
<Switch
|
||||
label="Pin Table Headers"
|
||||
description="Keep table headers visible when scrolling"
|
||||
checked={headerPinned}
|
||||
onChange={(event) =>
|
||||
onUISettingsChange('header-pinned', event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Time format"
|
||||
value={timeFormat}
|
||||
|
|
@ -136,7 +151,7 @@ const UiSettingsForm = React.memo(() => {
|
|||
onChange={(val) => onUISettingsChange('time-zone', val)}
|
||||
data={timeZoneOptions}
|
||||
/>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
|
|||
streams: newStreamList.map((s) => s.id),
|
||||
});
|
||||
await API.requeryChannels();
|
||||
await API.requeryStreams();
|
||||
};
|
||||
|
||||
// Create M3U account map for quick lookup
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ import React, {
|
|||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
|
|
@ -61,9 +72,18 @@ import ChannelTableStreams from './ChannelTableStreams';
|
|||
import LazyLogo from '../LazyLogo';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
|
||||
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
|
||||
import {
|
||||
EditableTextCell,
|
||||
EditableNumberCell,
|
||||
EditableGroupCell,
|
||||
EditableEPGCell,
|
||||
EditableLogoCell,
|
||||
} from './ChannelsTable/EditableCell';
|
||||
import { DraggableRow } from './ChannelsTable/DraggableRow';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
|
@ -114,6 +134,7 @@ const ChannelRowActions = React.memo(
|
|||
({
|
||||
theme,
|
||||
row,
|
||||
table,
|
||||
editChannel,
|
||||
deleteChannel,
|
||||
handleWatchStream,
|
||||
|
|
@ -123,7 +144,6 @@ const ChannelRowActions = React.memo(
|
|||
// Extract the channel ID once to ensure consistency
|
||||
const channelId = row.original.id;
|
||||
const channelUuid = row.original.uuid;
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
|
|
@ -149,6 +169,7 @@ const ChannelRowActions = React.memo(
|
|||
createRecording(row.original);
|
||||
}, [channelId]);
|
||||
|
||||
const tableSize = table?.tableSize ?? 'default';
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
|
|
@ -230,6 +251,10 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
|
||||
|
||||
// Get channel logos for logo selection
|
||||
const { ensureLogosLoaded } = useChannelLogoSelection();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
|
@ -257,6 +282,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams);
|
||||
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
|
||||
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
|
||||
// store/channels
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
|
|
@ -272,7 +298,6 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// store/settings
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
// store/warnings
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
|
|
@ -319,6 +344,18 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const hasFetchedData = useRef(false);
|
||||
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
|
||||
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
|
||||
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
|
||||
|
||||
// Drag-and-drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Require 8px movement before dragging starts
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Column sizing state for resizable columns
|
||||
// Store in localStorage but with empty object as default
|
||||
|
|
@ -367,6 +404,12 @@ const ChannelsTable = ({ onReady }) => {
|
|||
if (hasUnlinkedChannels) {
|
||||
epgOptions.unshift('No EPG');
|
||||
}
|
||||
// Map for MultiSelect: value 'null' for 'No EPG', label for display
|
||||
const epgSelectOptions = epgOptions.map((opt) =>
|
||||
opt === 'No EPG'
|
||||
? { value: 'null', label: 'No EPG' }
|
||||
: { value: opt, label: opt }
|
||||
);
|
||||
const debouncedFilters = useDebounce(filters, 500, () => {
|
||||
setPagination({
|
||||
...pagination,
|
||||
|
|
@ -383,8 +426,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
* Functions
|
||||
*/
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Build params first to check for duplicates
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
|
@ -407,7 +449,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (Array.isArray(value)) {
|
||||
// Convert null values to "null" string for URL parameter
|
||||
|
|
@ -421,34 +463,70 @@ const ChannelsTable = ({ onReady }) => {
|
|||
}
|
||||
});
|
||||
|
||||
const [results, ids] = await Promise.all([
|
||||
await API.queryChannels(params),
|
||||
await API.getAllChannelIds(params),
|
||||
]);
|
||||
const paramsString = params.toString();
|
||||
|
||||
setIsLoading(false);
|
||||
hasFetchedData.current = true;
|
||||
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
|
||||
if (
|
||||
fetchInProgressRef.current &&
|
||||
lastFetchParamsRef.current === paramsString
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTablePrefs({
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setAllRowIds(ids);
|
||||
// Increment fetch version to track this specific fetch request
|
||||
const currentFetchVersion = ++fetchVersionRef.current;
|
||||
lastFetchParamsRef.current = paramsString;
|
||||
fetchInProgressRef.current = true;
|
||||
|
||||
// Signal ready after first successful data fetch AND EPG data is loaded
|
||||
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [results, ids] = await Promise.all([
|
||||
API.queryChannels(params),
|
||||
API.getAllChannelIds(params),
|
||||
]);
|
||||
|
||||
fetchInProgressRef.current = false;
|
||||
|
||||
// Skip state updates if a newer fetch has been initiated
|
||||
if (currentFetchVersion !== fetchVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
hasFetchedData.current = true;
|
||||
|
||||
setTablePrefs((prev) => ({
|
||||
...prev,
|
||||
pageSize: pagination.pageSize,
|
||||
}));
|
||||
setAllRowIds(ids);
|
||||
|
||||
// Signal ready after first successful data fetch AND EPG data is loaded
|
||||
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
} catch (error) {
|
||||
fetchInProgressRef.current = false;
|
||||
|
||||
// Skip state updates if a newer fetch has been initiated
|
||||
if (currentFetchVersion !== fetchVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
// API layer handles "Invalid page" errors by resetting and retrying
|
||||
// Just re-throw to show notification for actual errors
|
||||
throw error;
|
||||
}
|
||||
}, [
|
||||
pagination,
|
||||
sorting,
|
||||
debouncedFilters,
|
||||
onReady,
|
||||
showDisabled,
|
||||
selectedProfileId,
|
||||
showOnlyStreamlessChannels,
|
||||
tvgsLoaded,
|
||||
]);
|
||||
|
||||
const stopPropagation = useCallback((e) => {
|
||||
|
|
@ -473,9 +551,9 @@ const ChannelsTable = ({ onReady }) => {
|
|||
};
|
||||
|
||||
const handleEPGChange = (value) => {
|
||||
// Convert "No EPG" to null for natural filtering
|
||||
// Map 'null' (string) back to 'null' for backend, but keep UI label correct
|
||||
const processedValue = value
|
||||
? value.map((v) => (v === 'No EPG' ? null : v))
|
||||
? value.map((v) => (v === 'null' ? 'null' : v))
|
||||
: '';
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -738,6 +816,47 @@ const ChannelsTable = ({ onReady }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = rows.findIndex((row) => row.id === active.id);
|
||||
const overIndex = rows.findIndex((row) => row.id === over.id);
|
||||
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChannel = rows[activeIndex].original;
|
||||
const overChannel = rows[overIndex].original;
|
||||
|
||||
try {
|
||||
// Optimistically update the local state
|
||||
const reorderedData = [...data];
|
||||
const [movedItem] = reorderedData.splice(activeIndex, 1);
|
||||
reorderedData.splice(overIndex, 0, movedItem);
|
||||
useChannelsTableStore.setState({ channels: reorderedData });
|
||||
|
||||
// Call backend to reorder
|
||||
await API.reorderChannel(
|
||||
activeChannel.id,
|
||||
overIndex > activeIndex
|
||||
? overChannel.id
|
||||
: rows[overIndex - 1]?.original.id || null
|
||||
);
|
||||
|
||||
// Refetch to get updated channel numbers
|
||||
await API.requeryChannels();
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
console.error('Failed to reorder channel:', error);
|
||||
await API.requeryChannels();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useEffect
|
||||
*/
|
||||
|
|
@ -809,22 +928,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
size: columnSizing.channel_number || 40,
|
||||
minSize: 30,
|
||||
maxSize: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
// Format as integer if no decimal component
|
||||
const formattedValue =
|
||||
value !== null && value !== undefined
|
||||
? value === Math.floor(value)
|
||||
? Math.floor(value)
|
||||
: value
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Flex justify="flex-end" style={{ width: '100%' }}>
|
||||
{formattedValue}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
cell: (props) => <EditableNumberCell {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -832,73 +936,20 @@ const ChannelsTable = ({ onReady }) => {
|
|||
size: columnSizing.name || 200,
|
||||
minSize: 100,
|
||||
grow: true,
|
||||
cell: ({ getValue }) => (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
cell: (props) => <EditableTextCell {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
header: 'EPG',
|
||||
accessorKey: 'epg_data_id',
|
||||
cell: ({ getValue }) => {
|
||||
const epgDataId = getValue();
|
||||
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
|
||||
const tvgName = epgObj?.name;
|
||||
const tvgId = epgObj?.tvg_id;
|
||||
const epgName =
|
||||
epgObj && epgObj.epg_source
|
||||
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
|
||||
: null;
|
||||
|
||||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
// If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{epgObj && epgName ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>
|
||||
}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span>
|
||||
{epgObj.epg_source} - {tvgId}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : epgObj ? (
|
||||
<span>{epgObj.name}</span>
|
||||
) : isEpgDataPending ? (
|
||||
<Skeleton
|
||||
height={14}
|
||||
width={(columnSizing.epg || 200) * 0.7}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Not Assigned</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
cell: (props) => (
|
||||
<EditableEPGCell
|
||||
{...props}
|
||||
tvgsById={tvgsById}
|
||||
epgs={epgs}
|
||||
tvgsLoaded={tvgsLoaded}
|
||||
/>
|
||||
),
|
||||
size: columnSizing.epg || 200,
|
||||
minSize: 80,
|
||||
},
|
||||
|
|
@ -908,16 +959,8 @@ const ChannelsTable = ({ onReady }) => {
|
|||
channelGroups[row.channel_group_id]
|
||||
? channelGroups[row.channel_group_id].name
|
||||
: '',
|
||||
cell: ({ getValue }) => (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
cell: (props) => (
|
||||
<EditableGroupCell {...props} channelGroups={channelGroups} />
|
||||
),
|
||||
size: columnSizing.channel_group || 175,
|
||||
minSize: 100,
|
||||
|
|
@ -933,29 +976,24 @@ const ChannelsTable = ({ onReady }) => {
|
|||
maxSize: 120,
|
||||
enableResizing: false,
|
||||
header: '',
|
||||
cell: ({ getValue }) => {
|
||||
const logoId = getValue();
|
||||
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<LazyLogo
|
||||
logoId={logoId}
|
||||
alt="logo"
|
||||
style={{ maxHeight: 18, maxWidth: 55 }}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
cell: (props) => (
|
||||
<EditableLogoCell
|
||||
{...props}
|
||||
LazyLogo={LazyLogo}
|
||||
ensureLogosLoaded={ensureLogosLoaded}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: tableSize == 'compact' ? 75 : 100,
|
||||
size: 100,
|
||||
enableResizing: false,
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row, table }) => (
|
||||
<ChannelRowActions
|
||||
theme={theme}
|
||||
row={row}
|
||||
table={table}
|
||||
editChannel={editChannel}
|
||||
deleteChannel={deleteChannel}
|
||||
handleWatchStream={handleWatchStream}
|
||||
|
|
@ -971,8 +1009,9 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// 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.
|
||||
// Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
|
||||
[selectedProfileId, channelGroups, theme, tvgsById, epgs]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
@ -991,13 +1030,20 @@ const ChannelsTable = ({ onReady }) => {
|
|||
<MultiSelect
|
||||
placeholder="EPG"
|
||||
variant="unstyled"
|
||||
data={epgOptions}
|
||||
data={epgSelectOptions}
|
||||
className="table-input-header"
|
||||
size="xs"
|
||||
searchable
|
||||
clearable
|
||||
onClick={stopPropagation}
|
||||
onChange={handleEPGChange}
|
||||
value={
|
||||
Array.isArray(filters.epg)
|
||||
? filters.epg
|
||||
: filters.epg
|
||||
? filters.epg.split(',').filter(Boolean)
|
||||
: []
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1056,6 +1102,13 @@ const ChannelsTable = ({ onReady }) => {
|
|||
clearable
|
||||
onClick={stopPropagation}
|
||||
onChange={handleGroupChange}
|
||||
value={
|
||||
Array.isArray(filters.channel_group)
|
||||
? filters.channel_group
|
||||
: filters.channel_group
|
||||
? filters.channel_group.split(',').filter(Boolean)
|
||||
: []
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1076,6 +1129,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
enableRowSelection: true,
|
||||
enableDragDrop: true,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
state: {
|
||||
pagination,
|
||||
|
|
@ -1440,7 +1494,18 @@ const ChannelsTable = ({ onReady }) => {
|
|||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={rows.map((row) => row.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -28,10 +28,15 @@ import {
|
|||
Filter,
|
||||
Square,
|
||||
SquareCheck,
|
||||
Pin,
|
||||
PinOff,
|
||||
Lock,
|
||||
LockOpen,
|
||||
} from 'lucide-react';
|
||||
import API from '../../../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
|
|
@ -105,6 +110,7 @@ const ChannelTableHeader = ({
|
|||
editChannel,
|
||||
deleteChannels,
|
||||
selectedTableIds,
|
||||
table,
|
||||
showDisabled,
|
||||
setShowDisabled,
|
||||
showOnlyStreamlessChannels,
|
||||
|
|
@ -131,6 +137,11 @@ const ChannelTableHeader = ({
|
|||
const authUser = useAuthStore((s) => s.user);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const setIsUnlocked = useChannelsTableStore((s) => s.setIsUnlocked);
|
||||
|
||||
const headerPinned = table?.headerPinned ?? false;
|
||||
const setHeaderPinned = table?.setHeaderPinned || (() => {});
|
||||
const closeAssignChannelNumbersModal = () => {
|
||||
setAssignNumbersModalOpen(false);
|
||||
};
|
||||
|
|
@ -229,6 +240,14 @@ const ChannelTableHeader = ({
|
|||
setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
|
||||
};
|
||||
|
||||
const toggleHeaderPinned = () => {
|
||||
setHeaderPinned(!headerPinned);
|
||||
};
|
||||
|
||||
const toggleUnlock = () => {
|
||||
setIsUnlocked(!isUnlocked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
|
|
@ -248,6 +267,23 @@ const ChannelTableHeader = ({
|
|||
<Tooltip label="Create Profile">
|
||||
<CreateProfilePopover />
|
||||
</Tooltip>
|
||||
|
||||
{isUnlocked && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="yellow.5"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingLeft: 10,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<LockOpen size={14} />
|
||||
Editing Mode
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
|
|
@ -346,6 +382,31 @@ const ChannelTableHeader = ({
|
|||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
headerPinned ? <Pin size={18} /> : <PinOff size={18} />
|
||||
}
|
||||
onClick={toggleHeaderPinned}
|
||||
>
|
||||
<Text size="xs">
|
||||
{headerPinned ? 'Unpin Headers' : 'Pin Headers'}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isUnlocked ? <LockOpen size={18} /> : <Lock size={18} />
|
||||
}
|
||||
onClick={toggleUnlock}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
>
|
||||
<Text size="xs">
|
||||
{isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
disabled={
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { Box } from '@mantine/core';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
export const DraggableRow = ({ row, children }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
disabled: !isUnlocked,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="tr">
|
||||
{isUnlocked && (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} opacity={0.5} />
|
||||
</Box>
|
||||
)}
|
||||
<div style={{ paddingLeft: isUnlocked ? 28 : 0, width: '100%' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
834
frontend/src/components/tables/ChannelsTable/EditableCell.jsx
Normal file
834
frontend/src/components/tables/ChannelsTable/EditableCell.jsx
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
memo,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextInput,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tooltip,
|
||||
Center,
|
||||
Skeleton,
|
||||
} from '@mantine/core';
|
||||
import API from '../../../api';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
import useLogosStore from '../../../store/logos';
|
||||
|
||||
// Lightweight wrapper that only renders full editable cell when unlocked
|
||||
// This prevents 250+ heavy component instances when table is locked
|
||||
const EditableCellWrapper = memo(
|
||||
({ children, getValue, isUnlocked, renderLocked }) => {
|
||||
if (!isUnlocked) {
|
||||
// Render lightweight locked view
|
||||
return renderLocked ? (
|
||||
renderLocked(getValue())
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{getValue() ?? ''}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Only render heavy component when unlocked
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
// Editable text cell
|
||||
export const EditableTextCell = ({ row, column, getValue }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// When locked or not focused, show simple display
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => isUnlocked && setIsFocused(true)}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: isUnlocked ? 'text' : 'default',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{getValue() ?? ''}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Only mount heavy component when actually editing
|
||||
return (
|
||||
<EditableTextCellInner
|
||||
row={row}
|
||||
column={column}
|
||||
getValue={getValue}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Inner component with all the editing logic - only rendered when focused
|
||||
const EditableTextCellInner = ({ row, column, getValue, onBlur }) => {
|
||||
const initialValue = getValue() || '';
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const previousValue = useRef(initialValue);
|
||||
const isMounted = useRef(false);
|
||||
const debounceTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = getValue() || '';
|
||||
if (currentValue !== previousValue.current) {
|
||||
setValue(currentValue);
|
||||
previousValue.current = currentValue;
|
||||
}
|
||||
}, [getValue]);
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newValue) => {
|
||||
// Don't save if not mounted or value hasn't changed
|
||||
if (!isMounted.current || newValue === previousValue.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
[column.id]: newValue || null,
|
||||
});
|
||||
previousValue.current = newValue;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setValue(previousValue.current || '');
|
||||
}
|
||||
},
|
||||
[row.original.id, column.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
const timer = debounceTimer.current;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setValue(newValue);
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
saveValue(newValue);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
saveValue(value);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
styles={{
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable number cell
|
||||
export const EditableNumberCell = ({ row, column, getValue }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const value = getValue();
|
||||
const formattedValue =
|
||||
value !== null && value !== undefined
|
||||
? value === Math.floor(value)
|
||||
? Math.floor(value)
|
||||
: value
|
||||
: '';
|
||||
|
||||
// When locked or not focused, show simple display
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => isUnlocked && setIsFocused(true)}
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
width: '100%',
|
||||
cursor: isUnlocked ? 'text' : 'default',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{formattedValue}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableNumberCellInner
|
||||
row={row}
|
||||
column={column}
|
||||
getValue={getValue}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Inner component with all the editing logic - only rendered when focused
|
||||
const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
|
||||
const initialValue = getValue();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const previousValue = useRef(initialValue);
|
||||
const isMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = getValue();
|
||||
if (currentValue !== previousValue.current) {
|
||||
setValue(currentValue);
|
||||
previousValue.current = currentValue;
|
||||
}
|
||||
}, [getValue]);
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newValue) => {
|
||||
// Don't save if not mounted or value hasn't changed
|
||||
if (!isMounted.current || newValue === previousValue.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For channel_number, don't save null/undefined values
|
||||
if (
|
||||
column.id === 'channel_number' &&
|
||||
(newValue === null || newValue === undefined || newValue === '')
|
||||
) {
|
||||
// Revert to previous value
|
||||
setValue(previousValue.current);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
[column.id]: newValue,
|
||||
});
|
||||
previousValue.current = newValue;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
|
||||
// If channel_number was changed, refetch to reorder the table
|
||||
if (column.id === 'channel_number') {
|
||||
await API.requeryChannels();
|
||||
onBlur();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setValue(previousValue.current);
|
||||
}
|
||||
},
|
||||
[row.original.id, column.id, onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (newValue) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
saveValue(value);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
hideControls
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
textAlign: 'right',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable select cell for groups
|
||||
export const EditableGroupCell = ({ row, channelGroups }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const groupId = row.original.channel_group_id;
|
||||
const groupName = channelGroups[groupId]?.name || '';
|
||||
|
||||
// Show simple display when locked OR when unlocked but not focused
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => isUnlocked && setIsFocused(true)}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 4px',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableGroupCellInner
|
||||
row={row}
|
||||
channelGroups={channelGroups}
|
||||
groupName={groupName}
|
||||
groupId={groupId}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Inner component with all the editing logic - only rendered when focused
|
||||
const EditableGroupCellInner = ({
|
||||
row,
|
||||
channelGroups,
|
||||
groupName,
|
||||
groupId,
|
||||
onBlur,
|
||||
}) => {
|
||||
const previousGroupId = useRef(groupId);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newGroupId) => {
|
||||
// Don't save if value hasn't changed
|
||||
if (String(newGroupId) === String(previousGroupId.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
channel_group_id: parseInt(newGroupId, 10),
|
||||
});
|
||||
previousGroupId.current = newGroupId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update channel group:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id]
|
||||
);
|
||||
|
||||
const handleChange = (newGroupId) => {
|
||||
saveValue(newGroupId);
|
||||
onBlur();
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const groupOptions = Object.values(channelGroups).map((group) => ({
|
||||
value: String(group.id),
|
||||
label: group.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
data={groupOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={groupName}
|
||||
nothingFoundMessage="No groups found"
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable select cell for EPG
|
||||
export const EditableEPGCell = ({
|
||||
row,
|
||||
getValue,
|
||||
tvgsById,
|
||||
epgs,
|
||||
tvgsLoaded,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const epgDataId = getValue();
|
||||
|
||||
// Format display text - needed for both locked and unlocked states
|
||||
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
|
||||
const tvgId = epgObj?.tvg_id;
|
||||
const epgName =
|
||||
epgObj && epgObj.epg_source
|
||||
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
|
||||
: null;
|
||||
const displayText =
|
||||
epgObj && epgName
|
||||
? `${epgObj.epg_source} - ${tvgId}`
|
||||
: epgObj
|
||||
? epgObj.name
|
||||
: 'Not Assigned';
|
||||
|
||||
// Show skeleton while EPG data is loading (only if channel has an EPG assignment)
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
// Build tooltip content
|
||||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
// Show simple display when locked OR when unlocked but not focused
|
||||
if (!isUnlocked || !isFocused) {
|
||||
// If loading EPG data, show skeleton
|
||||
if (isEpgDataPending) {
|
||||
return (
|
||||
<Box
|
||||
onClick={() => isUnlocked && setIsFocused(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
height={18}
|
||||
width="70%"
|
||||
visible={true}
|
||||
animate={true}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
label={<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>}
|
||||
withArrow
|
||||
position="top"
|
||||
disabled={!epgObj}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box
|
||||
onClick={() => isUnlocked && setIsFocused(true)}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 4px',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableEPGCellInner
|
||||
row={row}
|
||||
tvgsById={tvgsById}
|
||||
epgs={epgs}
|
||||
epgDataId={epgDataId}
|
||||
epgObj={epgObj}
|
||||
displayText={displayText}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Inner component with all the editing logic - only rendered when focused
|
||||
const EditableEPGCellInner = ({
|
||||
row,
|
||||
tvgsById,
|
||||
epgs,
|
||||
epgDataId,
|
||||
displayText,
|
||||
onBlur,
|
||||
}) => {
|
||||
const previousEpgDataId = useRef(epgDataId);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newEpgDataId) => {
|
||||
// Don't save if value hasn't changed
|
||||
if (String(newEpgDataId) === String(previousEpgDataId.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
epg_data_id:
|
||||
newEpgDataId === 'null' ? null : parseInt(newEpgDataId, 10),
|
||||
});
|
||||
previousEpgDataId.current = newEpgDataId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update EPG:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id]
|
||||
);
|
||||
|
||||
const handleChange = (newEpgDataId) => {
|
||||
saveValue(newEpgDataId);
|
||||
setSearchValue('');
|
||||
onBlur();
|
||||
};
|
||||
|
||||
// Build EPG options
|
||||
const epgOptions = useMemo(() => {
|
||||
const options = [{ value: 'null', label: 'Not Assigned' }];
|
||||
|
||||
// Convert tvgsById to an array and sort by EPG source name, then by tvg_id
|
||||
const tvgsArray = Object.values(tvgsById);
|
||||
tvgsArray.sort((a, b) => {
|
||||
const aEpgName =
|
||||
a.epg_source && epgs[a.epg_source]
|
||||
? epgs[a.epg_source].name
|
||||
: a.epg_source || '';
|
||||
const bEpgName =
|
||||
b.epg_source && epgs[b.epg_source]
|
||||
? epgs[b.epg_source].name
|
||||
: b.epg_source || '';
|
||||
const epgCompare = aEpgName.localeCompare(bEpgName);
|
||||
if (epgCompare !== 0) return epgCompare;
|
||||
// Secondary sort by tvg_id
|
||||
return (a.tvg_id || '').localeCompare(b.tvg_id || '');
|
||||
});
|
||||
|
||||
tvgsArray.forEach((tvg) => {
|
||||
const epgSourceName =
|
||||
tvg.epg_source && epgs[tvg.epg_source]
|
||||
? epgs[tvg.epg_source].name
|
||||
: tvg.epg_source;
|
||||
const tvgName = tvg.name;
|
||||
// Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
|
||||
let label;
|
||||
if (epgSourceName && tvg.tvg_id) {
|
||||
label = `${epgSourceName} | ${tvg.tvg_id}`;
|
||||
if (tvgName && tvgName !== tvg.tvg_id) {
|
||||
label += ` | ${tvgName}`;
|
||||
}
|
||||
} else if (tvgName) {
|
||||
label = tvgName;
|
||||
} else {
|
||||
label = `ID: ${tvg.id}`;
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: String(tvg.id),
|
||||
label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [tvgsById, epgs]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
data={epgOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={displayText}
|
||||
nothingFoundMessage="No EPG found"
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable cell for Logo selection
|
||||
export const EditableLogoCell = ({
|
||||
row,
|
||||
getValue,
|
||||
LazyLogo,
|
||||
ensureLogosLoaded,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const logoId = getValue();
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
// Ensure logos are loaded when user tries to edit
|
||||
ensureLogosLoaded?.();
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Show simple display when locked OR when unlocked but not focused
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{LazyLogo && (
|
||||
<LazyLogo
|
||||
logoId={logoId}
|
||||
alt="logo"
|
||||
style={{ maxHeight: 18, maxWidth: 55 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditableLogoCellInner
|
||||
row={row}
|
||||
logoId={logoId}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Inner component with all the editing logic - only rendered when focused
|
||||
const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
|
||||
// Subscribe directly to the logos store so we get updates when logos load
|
||||
const channelLogos = useLogosStore((s) => s.channelLogos);
|
||||
const previousLogoId = useRef(logoId);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newLogoId) => {
|
||||
// Don't save if value hasn't changed
|
||||
if (String(newLogoId) === String(previousLogoId.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
logo_id: newLogoId === 'null' ? null : parseInt(newLogoId, 10),
|
||||
});
|
||||
previousLogoId.current = newLogoId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update logo:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id]
|
||||
);
|
||||
|
||||
const handleChange = (newLogoId) => {
|
||||
saveValue(newLogoId);
|
||||
setSearchValue('');
|
||||
onBlur();
|
||||
};
|
||||
|
||||
// Build logo options with logo data
|
||||
const logoOptions = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
value: 'null',
|
||||
label: 'Default',
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Convert channelLogos object to array and sort by name
|
||||
const logosArray = Object.values(channelLogos);
|
||||
logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
logosArray.forEach((logo) => {
|
||||
options.push({
|
||||
value: String(logo.id),
|
||||
label: logo.name || `Logo ${logo.id}`,
|
||||
logo: logo,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [channelLogos]);
|
||||
|
||||
// Get display text for the current logo
|
||||
const displayText =
|
||||
logoId && channelLogos[logoId] ? channelLogos[logoId].name : 'Default';
|
||||
|
||||
// Custom option renderer to show logo images
|
||||
const renderOption = ({ option }) => {
|
||||
if (option.value === 'null') {
|
||||
return <div style={{ padding: '8px 12px' }}>Default</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
minHeight: '50px',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={option.logo?.cache_url}
|
||||
alt={option.label}
|
||||
style={{
|
||||
height: '40px',
|
||||
maxWidth: '100px',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '13px' }}>{option.label}</span>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
data={logoOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={displayText}
|
||||
nothingFoundMessage="No logos found"
|
||||
renderOption={renderOption}
|
||||
maxDropdownHeight={400}
|
||||
comboboxProps={{ width: 250, position: 'bottom-start' }}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
option: {
|
||||
padding: 0,
|
||||
},
|
||||
dropdown: {
|
||||
minWidth: '250px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import { Box, Flex } from '@mantine/core';
|
||||
import CustomTableHeader from './CustomTableHeader';
|
||||
import { useCallback, useState, useRef, useMemo } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import table from '../../../helpers/table';
|
||||
import CustomTableBody from './CustomTableBody';
|
||||
import useLocalStorage from '../../../hooks/useLocalStorage';
|
||||
|
||||
const CustomTable = ({ table }) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
const tableSize = table?.tableSize ?? 'default';
|
||||
|
||||
// Get column sizing state for dependency tracking
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
|
|
@ -34,7 +31,6 @@ const CustomTable = ({ table }) => {
|
|||
minWidth: `${minTableWidth}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CustomTableHeader
|
||||
|
|
@ -47,6 +43,8 @@ const CustomTable = ({ table }) => {
|
|||
}
|
||||
selectedTableIds={table.selectedTableIds}
|
||||
tableCellProps={table.tableCellProps}
|
||||
headerPinned={table.headerPinned}
|
||||
enableDragDrop={table.enableDragDrop}
|
||||
/>
|
||||
<CustomTableBody
|
||||
getRowModel={table.getRowModel}
|
||||
|
|
@ -55,9 +53,10 @@ const CustomTable = ({ table }) => {
|
|||
expandedRowRenderer={table.expandedRowRenderer}
|
||||
renderBodyCell={table.renderBodyCell}
|
||||
getExpandedRowHeight={table.getExpandedRowHeight}
|
||||
getRowStyles={table.getRowStyles} // Pass the getRowStyles function
|
||||
getRowStyles={table.getRowStyles}
|
||||
tableBodyProps={table.tableBodyProps}
|
||||
tableCellProps={table.tableCellProps}
|
||||
enableDragDrop={table.enableDragDrop}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { VariableSizeList as List } from 'react-window';
|
|||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { useMemo } from 'react';
|
||||
import table from '../../../helpers/table';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
const CustomTableBody = ({
|
||||
getRowModel,
|
||||
|
|
@ -10,9 +14,10 @@ const CustomTableBody = ({
|
|||
expandedRowRenderer,
|
||||
renderBodyCell,
|
||||
getExpandedRowHeight,
|
||||
getRowStyles, // Add this prop to receive row styles
|
||||
getRowStyles,
|
||||
tableBodyProps,
|
||||
tableCellProps,
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const renderExpandedRow = (row) => {
|
||||
if (expandedRowRenderer) {
|
||||
|
|
@ -101,7 +106,12 @@ const CustomTableBody = ({
|
|||
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
|
||||
|
||||
return (
|
||||
<Box style={style} key={`row-${row.id}`}>
|
||||
<DraggableRowWrapper
|
||||
row={row}
|
||||
key={`row-${row.id}`}
|
||||
style={style}
|
||||
enableDragDrop={enableDragDrop}
|
||||
>
|
||||
<Box
|
||||
key={`tr-${row.id}`}
|
||||
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'} ${customClassName}`}
|
||||
|
|
@ -145,11 +155,72 @@ const CustomTableBody = ({
|
|||
})}
|
||||
</Box>
|
||||
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
|
||||
</Box>
|
||||
</DraggableRowWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
return renderTableBodyContents();
|
||||
};
|
||||
|
||||
const DraggableRowWrapper = ({
|
||||
row,
|
||||
children,
|
||||
style = {},
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const shouldEnableDrag = enableDragDrop && isUnlocked;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
disabled: !shouldEnableDrag,
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
position: 'relative',
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={setNodeRef} style={dragStyle}>
|
||||
{shouldEnableDrag && (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} opacity={0.5} />
|
||||
</Box>
|
||||
)}
|
||||
<div style={{ paddingLeft: shouldEnableDrag ? 28 : 0, width: '100%' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTableBody;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Box, Center, Checkbox, Flex } from '@mantine/core';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
const CustomTableHeader = ({
|
||||
getHeaderGroups,
|
||||
|
|
@ -9,35 +11,48 @@ const CustomTableHeader = ({
|
|||
headerCellRenderFns,
|
||||
onSelectAllChange,
|
||||
tableCellProps,
|
||||
headerPinned = true,
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const shouldEnableDrag = enableDragDrop && isUnlocked;
|
||||
const renderHeaderCell = (header) => {
|
||||
let content;
|
||||
|
||||
if (headerCellRenderFns[header.id]) {
|
||||
return headerCellRenderFns[header.id](header);
|
||||
content = headerCellRenderFns[header.id](header);
|
||||
} else {
|
||||
switch (header.id) {
|
||||
case 'select':
|
||||
content = (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={
|
||||
allRowIds.length == 0
|
||||
? false
|
||||
: selectedTableIds.length == allRowIds.length
|
||||
}
|
||||
indeterminate={
|
||||
selectedTableIds.length > 0 &&
|
||||
selectedTableIds.length !== allRowIds.length
|
||||
}
|
||||
onChange={onSelectAllChange}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
content = flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (header.id) {
|
||||
case 'select':
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={
|
||||
allRowIds.length == 0
|
||||
? false
|
||||
: selectedTableIds.length == allRowIds.length
|
||||
}
|
||||
indeterminate={
|
||||
selectedTableIds.length > 0 &&
|
||||
selectedTableIds.length !== allRowIds.length
|
||||
}
|
||||
onChange={onSelectAllChange}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
|
||||
default:
|
||||
return flexRender(header.column.columnDef.header, header.getContext());
|
||||
}
|
||||
// Automatically wrap content to enhance MultiSelect components
|
||||
return <MultiSelectHeaderWrapper>{content}</MultiSelectHeaderWrapper>;
|
||||
};
|
||||
|
||||
// Get header groups for dependency tracking
|
||||
|
|
@ -59,15 +74,22 @@ const CustomTableHeader = ({
|
|||
return width;
|
||||
}, [headerGroups]);
|
||||
|
||||
// Memoize the style object to ensure it updates when headerPinned changes
|
||||
const headerStyle = useMemo(
|
||||
() => ({
|
||||
position: headerPinned ? 'sticky' : 'relative',
|
||||
top: headerPinned ? 0 : 'auto',
|
||||
backgroundColor: '#3E3E45',
|
||||
zIndex: headerPinned ? 10 : 1,
|
||||
}),
|
||||
[headerPinned]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="thead"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: '#3E3E45',
|
||||
zIndex: 10,
|
||||
}}
|
||||
style={headerStyle}
|
||||
data-header-pinned={headerPinned ? 'true' : 'false'}
|
||||
>
|
||||
{getHeaderGroups().map((headerGroup) => (
|
||||
<Box
|
||||
|
|
@ -77,6 +99,7 @@ const CustomTableHeader = ({
|
|||
display: 'flex',
|
||||
width: '100%',
|
||||
minWidth: '100%', // Force full width
|
||||
paddingLeft: shouldEnableDrag ? 28 : 0,
|
||||
}}
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import React, { cloneElement, isValidElement, useRef } from 'react';
|
||||
import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core';
|
||||
|
||||
/**
|
||||
* Automatically wraps MultiSelect components with pill display and tooltips
|
||||
* Recursively searches through React children to find and enhance MultiSelect
|
||||
*/
|
||||
const MultiSelectHeaderWrapper = ({ children }) => {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const enhanceMultiSelect = (element) => {
|
||||
if (!isValidElement(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
// Check if this element is a MultiSelect
|
||||
if (element.type === MultiSelect) {
|
||||
const { value = [], data = [], onChange, ...otherProps } = element.props;
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
|
||||
if (selectedValues.length === 0) {
|
||||
// No selections - just render the MultiSelect with hidden pills
|
||||
return cloneElement(element, {
|
||||
...otherProps,
|
||||
value,
|
||||
data,
|
||||
onChange,
|
||||
styles: { pill: { display: 'none' } },
|
||||
});
|
||||
}
|
||||
|
||||
// Get first label
|
||||
const firstLabel =
|
||||
data.find((opt) => opt.value === selectedValues[0])?.label ||
|
||||
selectedValues[0];
|
||||
|
||||
// Build tooltip content
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
{selectedValues.slice(0, 10).map((val, idx) => {
|
||||
const label = data.find((opt) => opt.value === val)?.label || val;
|
||||
return <div key={idx}>{label}</div>;
|
||||
})}
|
||||
{selectedValues.length > 10 && (
|
||||
<div style={{ marginTop: '4px', fontStyle: 'italic' }}>
|
||||
+{selectedValues.length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle opening the dropdown when pill is clicked
|
||||
const handlePillClick = (e) => {
|
||||
// Check if the click is on the remove button (it has a data-attribute)
|
||||
if (e.target.closest('[data-disabled]') || e.target.closest('button')) {
|
||||
return; // Let the remove button handle it
|
||||
}
|
||||
e.stopPropagation();
|
||||
// Focus and click the input to open the dropdown
|
||||
if (inputRef.current) {
|
||||
const input = inputRef.current.querySelector('input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing a single filter value
|
||||
const handleRemoveFirst = (e) => {
|
||||
e?.stopPropagation?.();
|
||||
if (onChange && selectedValues.length > 0) {
|
||||
const newValues = selectedValues.slice(1);
|
||||
onChange(newValues);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clearing all filters
|
||||
const handleClearAll = (e) => {
|
||||
e?.stopPropagation?.();
|
||||
if (onChange) {
|
||||
onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={inputRef} style={{ width: '100%', position: 'relative' }}>
|
||||
<Tooltip label={tooltipContent} position="top" withArrow>
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
right: 20,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Pill
|
||||
size="xs"
|
||||
withRemoveButton
|
||||
onRemove={handleRemoveFirst}
|
||||
onClick={handlePillClick}
|
||||
removeButtonProps={{
|
||||
onClick: handleRemoveFirst,
|
||||
style: { cursor: 'pointer' },
|
||||
}}
|
||||
style={{
|
||||
flex: selectedValues.length > 1 ? '1 1 auto' : '0 1 auto',
|
||||
minWidth: 0,
|
||||
maxWidth:
|
||||
selectedValues.length > 1 ? 'calc(100% - 40px)' : '100%',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{firstLabel}
|
||||
</span>
|
||||
</Pill>
|
||||
{selectedValues.length > 1 && (
|
||||
<Pill
|
||||
size="xs"
|
||||
withRemoveButton
|
||||
onRemove={handleClearAll}
|
||||
onClick={handlePillClick}
|
||||
removeButtonProps={{
|
||||
onClick: handleClearAll,
|
||||
style: { cursor: 'pointer' },
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
+{selectedValues.length - 1}
|
||||
</span>
|
||||
</Pill>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
{cloneElement(element, {
|
||||
...otherProps,
|
||||
value,
|
||||
data,
|
||||
onChange,
|
||||
styles: { pill: { display: 'none' } },
|
||||
style: { width: '100%', ...otherProps.style },
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if element has children - recursively enhance them
|
||||
if (element.props && element.props.children) {
|
||||
const enhancedChildren = React.Children.map(
|
||||
element.props.children,
|
||||
(child) => enhanceMultiSelect(child)
|
||||
);
|
||||
|
||||
// Clone element with enhanced children
|
||||
return cloneElement(element, {}, enhancedChildren);
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
return <>{enhanceMultiSelect(children)}</>;
|
||||
};
|
||||
|
||||
export default MultiSelectHeaderWrapper;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Center, Checkbox } from '@mantine/core';
|
||||
import CustomTable from './CustomTable';
|
||||
import CustomTableHeader from './CustomTableHeader';
|
||||
import useTablePreferences from '../../../hooks/useTablePreferences';
|
||||
|
||||
import {
|
||||
useReactTable,
|
||||
|
|
@ -27,6 +28,10 @@ const useTable = ({
|
|||
const [lastClickedId, setLastClickedId] = useState(null);
|
||||
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
|
||||
|
||||
// Use shared table preferences hook
|
||||
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
|
||||
useTablePreferences();
|
||||
|
||||
// Event handlers for shift key detection with improved handling
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Shift') {
|
||||
|
|
@ -244,8 +249,22 @@ const useTable = ({
|
|||
expandedRowRenderer,
|
||||
setSelectedTableIds,
|
||||
isShiftKeyDown, // Include shift key state in the table instance
|
||||
headerPinned,
|
||||
setHeaderPinned,
|
||||
tableSize,
|
||||
setTableSize,
|
||||
}),
|
||||
[selectedTableIdsSet, expandedRowIds, allRowIds, isShiftKeyDown]
|
||||
[
|
||||
selectedTableIdsSet,
|
||||
expandedRowIds,
|
||||
allRowIds,
|
||||
isShiftKeyDown,
|
||||
options,
|
||||
headerPinned,
|
||||
setHeaderPinned,
|
||||
tableSize,
|
||||
setTableSize,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import API from '../../api';
|
|||
import useEPGsStore from '../../store/epgs';
|
||||
import EPGForm from '../forms/EPG';
|
||||
import DummyEPGForm from '../forms/DummyEPG';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import {
|
||||
ActionIcon,
|
||||
Text,
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
Flex,
|
||||
useMantineTheme,
|
||||
Switch,
|
||||
Badge,
|
||||
Progress,
|
||||
Stack,
|
||||
Group,
|
||||
|
|
@ -31,9 +29,9 @@ import {
|
|||
SquarePlus,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import { format } from '../../utils/dateTimeUtils.js';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import { useDateTimeFormat } from '../../utils/dateTimeUtils.js';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
|
|
@ -116,17 +114,8 @@ const EPGsTable = () => {
|
|||
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
// Get tableSize directly from localStorage instead of the store
|
||||
const { fullDateTimeFormat } = useDateTimeFormat();
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
// Get proper size for action icons to match ChannelsTable
|
||||
const iconSize =
|
||||
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm';
|
||||
|
||||
// Calculate density for Mantine Table
|
||||
const tableDensity =
|
||||
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
|
||||
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
||||
|
|
@ -356,11 +345,11 @@ const EPGsTable = () => {
|
|||
enableSorting: false,
|
||||
cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
return value ? (
|
||||
<Text size="xs">{new Date(value).toLocaleString()}</Text>
|
||||
) : (
|
||||
<Text size="xs">Never</Text>
|
||||
);
|
||||
if (!value) {
|
||||
return <Text size="xs">Never</Text>;
|
||||
}
|
||||
const formatted = format(value, fullDateTimeFormat);
|
||||
return <Text size="xs">{formatted}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -391,7 +380,7 @@ const EPGsTable = () => {
|
|||
size: tableSize == 'compact' ? 75 : 100,
|
||||
},
|
||||
],
|
||||
[refreshProgress]
|
||||
[refreshProgress, fullDateTimeFormat]
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ import {
|
|||
ActionIcon,
|
||||
Tooltip,
|
||||
Switch,
|
||||
Progress,
|
||||
Stack,
|
||||
Badge,
|
||||
Group,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
|
|
@ -29,16 +26,13 @@ import {
|
|||
SquareMinus,
|
||||
SquarePen,
|
||||
RefreshCcw,
|
||||
Check,
|
||||
X,
|
||||
ArrowUpDown,
|
||||
ArrowUpNarrowWide,
|
||||
ArrowDownWideNarrow,
|
||||
SquarePlus,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
|
|
@ -131,9 +125,7 @@ const RowActions = ({
|
|||
const M3UTable = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
const [playlistCreated, setPlaylistCreated] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
|
@ -152,6 +144,7 @@ const M3UTable = () => {
|
|||
|
||||
const theme = useMantineTheme();
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
const { fullDateTimeFormat } = useDateTimeFormat();
|
||||
|
||||
const generateStatusString = (data) => {
|
||||
if (data.progress == 100) {
|
||||
|
|
@ -582,11 +575,11 @@ const M3UTable = () => {
|
|||
size: 175,
|
||||
cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
return value ? (
|
||||
<Text size="xs">{new Date(value).toLocaleString()}</Text>
|
||||
) : (
|
||||
<Text size="xs">Never</Text>
|
||||
);
|
||||
if (!value) {
|
||||
return <Text size="xs">Never</Text>;
|
||||
}
|
||||
const formatted = format(value, fullDateTimeFormat);
|
||||
return <Text size="xs">{formatted}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -611,7 +604,13 @@ const M3UTable = () => {
|
|||
size: tableSize == 'compact' ? 75 : 100,
|
||||
},
|
||||
],
|
||||
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
|
||||
[
|
||||
refreshPlaylist,
|
||||
editPlaylist,
|
||||
deletePlaylist,
|
||||
toggleActive,
|
||||
fullDateTimeFormat,
|
||||
]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import {
|
|||
ArrowUpNarrowWide,
|
||||
ArrowDownWideNarrow,
|
||||
Search,
|
||||
Filter,
|
||||
Square,
|
||||
SquareCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TextInput,
|
||||
|
|
@ -43,12 +46,12 @@ import {
|
|||
MultiSelect,
|
||||
useMantineTheme,
|
||||
UnstyledButton,
|
||||
LoadingOverlay,
|
||||
Skeleton,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Checkbox,
|
||||
LoadingOverlay,
|
||||
Pill,
|
||||
} from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
|
|
@ -59,6 +62,7 @@ import { CustomTable, useTable } from './CustomTable';
|
|||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import CreateChannelModal from '../modals/CreateChannelModal';
|
||||
import useStreamsTableStore from '../../store/streamsTable';
|
||||
|
||||
const StreamRowActions = ({
|
||||
theme,
|
||||
|
|
@ -68,8 +72,9 @@ const StreamRowActions = ({
|
|||
handleWatchStream,
|
||||
selectedChannelIds,
|
||||
createChannelFromStream,
|
||||
table,
|
||||
}) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
const tableSize = table?.tableSize ?? 'default';
|
||||
const channelSelectionStreams = useChannelsTableStore(
|
||||
(state) =>
|
||||
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
|
||||
|
|
@ -112,7 +117,7 @@ const StreamRowActions = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Add to Channel">
|
||||
<Tooltip label="Add to Channel" openDelay={500}>
|
||||
<ActionIcon
|
||||
size={iconSize}
|
||||
color={theme.tailwind.blue[6]}
|
||||
|
|
@ -131,7 +136,7 @@ const StreamRowActions = ({
|
|||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Create New Channel">
|
||||
<Tooltip label="Create New Channel" openDelay={500}>
|
||||
<ActionIcon
|
||||
size={iconSize}
|
||||
color={theme.tailwind.green[5]}
|
||||
|
|
@ -177,22 +182,24 @@ const StreamRowActions = ({
|
|||
const StreamsTable = ({ onReady }) => {
|
||||
const theme = useMantineTheme();
|
||||
const hasSignaledReady = useRef(false);
|
||||
const hasFetchedOnce = useRef(false);
|
||||
const hasFetchedPlaylists = useRef(false);
|
||||
const hasFetchedChannelGroups = useRef(false);
|
||||
|
||||
/**
|
||||
* useState
|
||||
*/
|
||||
const [allRowIds, setAllRowIds] = useState([]);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [m3uOptions, setM3uOptions] = useState([]);
|
||||
const [initialDataCount, setInitialDataCount] = useState(null);
|
||||
|
||||
const [data, setData] = useState([]); // Holds fetched data
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([{ id: 'name', desc: false }]);
|
||||
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
|
||||
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
|
||||
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
|
||||
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
|
||||
|
||||
// Channel creation modal state (bulk)
|
||||
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
|
||||
|
|
@ -224,14 +231,12 @@ const StreamsTable = ({ onReady }) => {
|
|||
'streams-page-size',
|
||||
50
|
||||
);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: storedPageSize,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
channel_group: '',
|
||||
m3u_account: '',
|
||||
unassigned: false,
|
||||
hide_stale: false,
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage(
|
||||
'streams-table-column-sizing',
|
||||
|
|
@ -239,21 +244,20 @@ const StreamsTable = ({ onReady }) => {
|
|||
);
|
||||
const debouncedFilters = useDebounce(filters, 500, () => {
|
||||
// Reset to first page whenever filters change to avoid "Invalid page" errors
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// Add state to track if stream groups are loaded
|
||||
const [groupsLoaded, setGroupsLoaded] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Stores
|
||||
*/
|
||||
const playlists = usePlaylistsStore((s) => s.playlists);
|
||||
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
|
||||
const playlistsLoading = usePlaylistsStore((s) => s.isLoading);
|
||||
|
||||
// Get direct access to channel groups without depending on other data
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
|
|
@ -268,7 +272,20 @@ const StreamsTable = ({ onReady }) => {
|
|||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const data = useStreamsTableStore((s) => s.streams);
|
||||
const pageCount = useStreamsTableStore((s) => s.pageCount);
|
||||
const totalCount = useStreamsTableStore((s) => s.totalCount);
|
||||
const allRowIds = useStreamsTableStore((s) => s.allQueryIds);
|
||||
const setAllRowIds = useStreamsTableStore((s) => s.setAllQueryIds);
|
||||
const pagination = useStreamsTableStore((s) => s.pagination);
|
||||
const setPagination = useStreamsTableStore((s) => s.setPagination);
|
||||
const sorting = useStreamsTableStore((s) => s.sorting);
|
||||
const setSorting = useStreamsTableStore((s) => s.setSorting);
|
||||
const selectedStreamIds = useStreamsTableStore((s) => s.selectedStreamIds);
|
||||
const setSelectedStreamIds = useStreamsTableStore(
|
||||
(s) => s.setSelectedStreamIds
|
||||
);
|
||||
|
||||
// Warnings store for "remember choice" functionality
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
|
@ -286,7 +303,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
() => [
|
||||
{
|
||||
id: 'actions',
|
||||
size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
|
||||
size: columnSizing.actions || 75,
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
|
|
@ -354,7 +371,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
),
|
||||
},
|
||||
],
|
||||
[channelGroups, playlists, columnSizing, tableSize]
|
||||
[channelGroups, playlists, columnSizing]
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -371,98 +388,145 @@ const StreamsTable = ({ onReady }) => {
|
|||
const handleGroupChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
channel_group: value ? value : '',
|
||||
channel_group: value && value.length > 0 ? value.join(',') : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleM3UChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
m3u_account: value ? value : '',
|
||||
m3u_account: value && value.length > 0 ? value.join(',') : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const toggleUnassignedOnly = () => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
unassigned: !prev.unassigned,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleHideStale = () => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
hide_stale: !prev.hide_stale,
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(
|
||||
async ({ showLoader = true } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const columnId = sorting[0].id;
|
||||
// Map frontend column IDs to backend field names
|
||||
const fieldMapping = {
|
||||
name: 'name',
|
||||
group: 'channel_group__name',
|
||||
m3u: 'm3u_account__name',
|
||||
};
|
||||
const sortField = fieldMapping[columnId] || columnId;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters; send boolean filters as 'true' when set
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
if (value) params.append(key, 'true');
|
||||
} else if (value !== null && value !== undefined && value !== '') {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const paramsString = params.toString();
|
||||
|
||||
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
|
||||
if (
|
||||
fetchInProgressRef.current &&
|
||||
lastFetchParamsRef.current === paramsString
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment fetch version to track this specific fetch request
|
||||
const currentFetchVersion = ++fetchVersionRef.current;
|
||||
lastFetchParamsRef.current = paramsString;
|
||||
fetchInProgressRef.current = true;
|
||||
|
||||
if (showLoader) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Ensure we have channel groups first (if not already loaded)
|
||||
if (!groupsLoaded && Object.keys(channelGroups).length === 0) {
|
||||
try {
|
||||
await fetchChannelGroups();
|
||||
setGroupsLoaded(true);
|
||||
const [result, ids, filterOptions] = await Promise.all([
|
||||
API.queryStreamsTable(params),
|
||||
API.getAllStreamIds(params),
|
||||
API.getStreamFilterOptions(params),
|
||||
]);
|
||||
|
||||
fetchInProgressRef.current = false;
|
||||
|
||||
// Skip state updates if a newer fetch has been initiated
|
||||
if (currentFetchVersion !== fetchVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAllRowIds(ids);
|
||||
|
||||
// Set filtered options based on current filters
|
||||
// Ensure groupOptions is always an array of valid strings
|
||||
if (filterOptions && typeof filterOptions === 'object') {
|
||||
setGroupOptions(
|
||||
(filterOptions.groups || [])
|
||||
.filter((group) => group != null && group !== '')
|
||||
.map((group) => String(group))
|
||||
);
|
||||
// Ensure m3uOptions is always an array of valid objects
|
||||
setM3uOptions(
|
||||
(filterOptions.m3u_accounts || [])
|
||||
.filter((m3u) => m3u && m3u.id != null && m3u.name)
|
||||
.map((m3u) => ({
|
||||
label: String(m3u.name),
|
||||
value: String(m3u.id),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (initialDataCount === null) {
|
||||
setInitialDataCount(result.count);
|
||||
}
|
||||
|
||||
// Signal that initial data load is complete
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching channel groups:', error);
|
||||
}
|
||||
}
|
||||
fetchInProgressRef.current = false;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const columnId = sorting[0].id;
|
||||
// Map frontend column IDs to backend field names
|
||||
const fieldMapping = {
|
||||
name: 'name',
|
||||
group: 'channel_group__name',
|
||||
m3u: 'm3u_account__name',
|
||||
};
|
||||
const sortField = fieldMapping[columnId] || columnId;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const [result, ids, groups] = await Promise.all([
|
||||
API.queryStreams(params),
|
||||
API.getAllStreamIds(params),
|
||||
API.getStreamGroups(),
|
||||
]);
|
||||
|
||||
setAllRowIds(ids);
|
||||
setData(result.results);
|
||||
setPageCount(Math.ceil(result.count / pagination.pageSize));
|
||||
setGroupOptions(groups);
|
||||
|
||||
// Calculate the starting and ending item indexes
|
||||
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
|
||||
const endItem = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
result.count
|
||||
);
|
||||
|
||||
if (initialDataCount === null) {
|
||||
setInitialDataCount(result.count);
|
||||
// Skip logging if a newer fetch has been initiated
|
||||
if (currentFetchVersion !== fetchVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Skip state updates if a newer fetch has been initiated
|
||||
if (currentFetchVersion !== fetchVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [
|
||||
pagination,
|
||||
sorting,
|
||||
debouncedFilters,
|
||||
groupsLoaded,
|
||||
channelGroups,
|
||||
fetchChannelGroups,
|
||||
onReady,
|
||||
]);
|
||||
hasFetchedOnce.current = true;
|
||||
if (showLoader) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[pagination, sorting, debouncedFilters, onReady]
|
||||
);
|
||||
|
||||
// Bulk creation: create channels from selected streams asynchronously
|
||||
const createChannelsFromStreams = async () => {
|
||||
|
|
@ -535,6 +599,8 @@ const StreamsTable = ({ onReady }) => {
|
|||
|
||||
// Clear selection since the task has started
|
||||
setSelectedStreamIds([]);
|
||||
|
||||
// Note: This is a background task, so the update happens on WebSocket completion
|
||||
} catch (error) {
|
||||
console.error('Error starting bulk channel creation:', error);
|
||||
// Error notifications will be handled by WebSocket
|
||||
|
|
@ -592,14 +658,15 @@ const StreamsTable = ({ onReady }) => {
|
|||
|
||||
const executeDeleteStream = async (id) => {
|
||||
setDeleting(true);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await API.deleteStream(id);
|
||||
fetchData();
|
||||
// Clear the selection for the deleted stream
|
||||
setSelectedStreamIds([]);
|
||||
table.setSelectedTableIds([]);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -617,11 +684,10 @@ const StreamsTable = ({ onReady }) => {
|
|||
};
|
||||
|
||||
const executeDeleteStreams = async () => {
|
||||
setIsLoading(true);
|
||||
setDeleting(true);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await API.deleteStreams(selectedStreamIds);
|
||||
fetchData();
|
||||
setSelectedStreamIds([]);
|
||||
table.setSelectedTableIds([]);
|
||||
} finally {
|
||||
|
|
@ -631,10 +697,15 @@ const StreamsTable = ({ onReady }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const closeStreamForm = () => {
|
||||
const closeStreamForm = async () => {
|
||||
setStream(null);
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await API.requeryStreams();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Single channel creation functions
|
||||
|
|
@ -702,8 +773,8 @@ const StreamsTable = ({ onReady }) => {
|
|||
channel_profile_ids: channelProfileIds,
|
||||
});
|
||||
await API.requeryChannels();
|
||||
const fetchLogos = useChannelsStore.getState().fetchLogos;
|
||||
fetchLogos();
|
||||
// const fetchLogos = useChannelsStore.getState().fetchLogos;
|
||||
// fetchLogos();
|
||||
};
|
||||
|
||||
// Handle confirming the single channel numbering modal
|
||||
|
|
@ -844,38 +915,35 @@ const StreamsTable = ({ onReady }) => {
|
|||
</Flex>
|
||||
);
|
||||
|
||||
case 'group':
|
||||
case 'group': {
|
||||
const selectedGroups = filters.channel_group
|
||||
? filters.channel_group.split(',').filter(Boolean)
|
||||
: [];
|
||||
return (
|
||||
<MultiSelect
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFoundMessage="No options"
|
||||
onClick={handleSelectClick}
|
||||
onChange={handleGroupChange}
|
||||
value={selectedGroups}
|
||||
data={groupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header custom-multiselect"
|
||||
clearable
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'm3u': {
|
||||
const selectedM3Us = filters.m3u_account
|
||||
? filters.m3u_account.split(',').filter(Boolean)
|
||||
: [];
|
||||
return (
|
||||
<Flex align="center" style={{ width: '100%', flex: 1 }}>
|
||||
<MultiSelect
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFoundMessage="No options"
|
||||
onClick={handleSelectClick}
|
||||
onChange={handleGroupChange}
|
||||
data={groupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header custom-multiselect"
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={React.createElement(sortingIcon, {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onSortingChange('group');
|
||||
},
|
||||
size: 14,
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case 'm3u':
|
||||
return (
|
||||
<Flex align="center" style={{ width: '100%', flex: 1 }}>
|
||||
<Select
|
||||
placeholder="M3U"
|
||||
searchable
|
||||
clearable
|
||||
|
|
@ -883,12 +951,10 @@ const StreamsTable = ({ onReady }) => {
|
|||
nothingFoundMessage="No options"
|
||||
onClick={handleSelectClick}
|
||||
onChange={handleM3UChange}
|
||||
data={playlists.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: `${playlist.id}`,
|
||||
}))}
|
||||
value={selectedM3Us}
|
||||
data={m3uOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
className="table-input-header custom-multiselect"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={React.createElement(sortingIcon, {
|
||||
|
|
@ -902,6 +968,7 @@ const StreamsTable = ({ onReady }) => {
|
|||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -946,6 +1013,10 @@ const StreamsTable = ({ onReady }) => {
|
|||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
enableRowSelection: true,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
headerCellRenderFns: {
|
||||
name: renderHeaderCell,
|
||||
group: renderHeaderCell,
|
||||
|
|
@ -972,6 +1043,88 @@ const StreamsTable = ({ onReady }) => {
|
|||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
Object.keys(channelGroups).length > 0 ||
|
||||
hasFetchedChannelGroups.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
hasFetchedChannelGroups.current = true;
|
||||
try {
|
||||
await fetchChannelGroups();
|
||||
} catch (error) {
|
||||
console.error('Error fetching channel groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadGroups();
|
||||
}, [channelGroups, fetchChannelGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
playlists.length > 0 ||
|
||||
hasFetchedPlaylists.current ||
|
||||
playlistsLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
hasFetchedPlaylists.current = true;
|
||||
try {
|
||||
await fetchPlaylists();
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlists:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlaylists();
|
||||
}, [playlists, fetchPlaylists, playlistsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const startItem = pagination.pageIndex * pagination.pageSize + 1;
|
||||
const endItem = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
totalCount
|
||||
);
|
||||
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
|
||||
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
|
||||
|
||||
// Clear dependent filters if selected values are no longer in filtered options
|
||||
useEffect(() => {
|
||||
// Clear group filter if the selected groups are no longer available
|
||||
if (filters.channel_group) {
|
||||
const selectedGroups = filters.channel_group.split(',').filter(Boolean);
|
||||
const stillValid = selectedGroups.filter((group) =>
|
||||
groupOptions.includes(group)
|
||||
);
|
||||
|
||||
if (stillValid.length !== selectedGroups.length) {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
channel_group: stillValid.join(','),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear M3U filter if the selected M3Us are no longer available
|
||||
if (filters.m3u_account) {
|
||||
const selectedIds = filters.m3u_account.split(',').filter(Boolean);
|
||||
const availableIds = m3uOptions.map((opt) => opt.value);
|
||||
const stillValid = selectedIds.filter((id) => availableIds.includes(id));
|
||||
|
||||
if (stillValid.length !== selectedIds.length) {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
m3u_account: stillValid.join(','),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [groupOptions, m3uOptions, filters.channel_group, filters.m3u_account]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
|
|
@ -1010,78 +1163,132 @@ const StreamsTable = ({ onReady }) => {
|
|||
gap={6}
|
||||
>
|
||||
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? 'light'
|
||||
: 'default'
|
||||
}
|
||||
size="xs"
|
||||
onClick={addStreamsToChannel}
|
||||
p={5}
|
||||
color={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? theme.tailwind.green[5]
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
!(
|
||||
<Tooltip
|
||||
label="Add selected stream(s) to the selected channel"
|
||||
openDelay={500}
|
||||
>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant={
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
)
|
||||
}
|
||||
>
|
||||
Add Streams to Channel
|
||||
</Button>
|
||||
? 'light'
|
||||
: 'default'
|
||||
}
|
||||
size="xs"
|
||||
onClick={addStreamsToChannel}
|
||||
p={5}
|
||||
color={
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
? theme.tailwind.green[5]
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
!(
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
)
|
||||
}
|
||||
>
|
||||
Add to Channel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={createChannelsFromStreams}
|
||||
p={5}
|
||||
disabled={selectedStreamIds.length == 0}
|
||||
<Tooltip
|
||||
label={`Create channels from ${selectedStreamIds.length} stream(s)`}
|
||||
openDelay={500}
|
||||
>
|
||||
{`Create Channels (${selectedStreamIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={createChannelsFromStreams}
|
||||
p={5}
|
||||
disabled={selectedStreamIds.length == 0}
|
||||
>
|
||||
{`Create Channels (${selectedStreamIds.length})`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editStream()}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Create Stream
|
||||
</Button>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Tooltip label="Filters" openDelay={500}>
|
||||
<Button size="xs" variant="default">
|
||||
<Filter size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteStreams}
|
||||
disabled={selectedStreamIds.length == 0}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={toggleUnassignedOnly}
|
||||
leftSection={
|
||||
filters.unassigned === true ? (
|
||||
<SquareCheck size={18} />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">Only Unassociated</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={toggleHideStale}
|
||||
leftSection={
|
||||
filters.hide_stale === true ? (
|
||||
<SquareCheck size={18} />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="xs">Hide Stale</Text>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Tooltip label="Create a new custom stream" openDelay={500}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editStream()}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Create Stream
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete selected stream(s)" openDelay={500}>
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteStreams}
|
||||
disabled={selectedStreamIds.length == 0}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,7 @@ import useUsersStore from '../../store/users';
|
|||
import useAuthStore from '../../store/auth';
|
||||
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import {
|
||||
SquarePlus,
|
||||
SquareMinus,
|
||||
SquarePen,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
|
|
@ -22,14 +15,13 @@ import {
|
|||
Flex,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
|
||||
|
||||
const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
|
@ -78,6 +70,7 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
|
|||
|
||||
const UsersTable = () => {
|
||||
const theme = useMantineTheme();
|
||||
const { fullDateFormat, fullDateTimeFormat } = useDateTimeFormat();
|
||||
|
||||
/**
|
||||
* STORES
|
||||
|
|
@ -210,9 +203,7 @@ const UsersTable = () => {
|
|||
cell: ({ getValue }) => {
|
||||
const date = getValue();
|
||||
return (
|
||||
<Text size="sm">
|
||||
{date ? new Date(date).toLocaleDateString() : '-'}
|
||||
</Text>
|
||||
<Text size="sm">{date ? format(date, fullDateFormat) : '-'}</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -224,7 +215,7 @@ const UsersTable = () => {
|
|||
const date = getValue();
|
||||
return (
|
||||
<Text size="sm">
|
||||
{date ? new Date(date).toLocaleString() : 'Never'}
|
||||
{date ? format(date, fullDateTimeFormat) : 'Never'}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
|
|
@ -280,7 +271,15 @@ const UsersTable = () => {
|
|||
),
|
||||
},
|
||||
],
|
||||
[theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility]
|
||||
[
|
||||
theme,
|
||||
editUser,
|
||||
deleteUser,
|
||||
visiblePasswords,
|
||||
togglePasswordVisibility,
|
||||
fullDateFormat,
|
||||
fullDateTimeFormat,
|
||||
]
|
||||
);
|
||||
|
||||
const closeUserForm = () => {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default function VODLogosTable() {
|
|||
deleteVODLogo,
|
||||
deleteVODLogos,
|
||||
cleanupUnusedVODLogos,
|
||||
getUnusedLogosCount,
|
||||
} = useVODLogosStore();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -77,14 +78,9 @@ export default function VODLogosTable() {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false);
|
||||
const [unusedLogosCount, setUnusedLogosCount] = useState(0);
|
||||
const [loadingUnusedCount, setLoadingUnusedCount] = useState(false);
|
||||
const tableRef = React.useRef(null);
|
||||
|
||||
// Calculate unused logos count
|
||||
const unusedLogosCount = useMemo(() => {
|
||||
return logos.filter(
|
||||
(logo) => logo.movie_count === 0 && logo.series_count === 0
|
||||
).length;
|
||||
}, [logos]);
|
||||
useEffect(() => {
|
||||
fetchVODLogos({
|
||||
page: currentPage,
|
||||
|
|
@ -94,6 +90,23 @@ export default function VODLogosTable() {
|
|||
});
|
||||
}, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
|
||||
|
||||
// Fetch the total count of unused logos
|
||||
useEffect(() => {
|
||||
const fetchUnusedCount = async () => {
|
||||
setLoadingUnusedCount(true);
|
||||
try {
|
||||
const count = await getUnusedLogosCount();
|
||||
setUnusedLogosCount(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unused logos count:', error);
|
||||
} finally {
|
||||
setLoadingUnusedCount(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnusedCount();
|
||||
}, [getUnusedLogosCount]);
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(checked) => {
|
||||
if (checked) {
|
||||
|
|
@ -185,6 +198,9 @@ export default function VODLogosTable() {
|
|||
message: `Cleaned up ${result.deleted_count} unused VOD logos`,
|
||||
color: 'green',
|
||||
});
|
||||
// Refresh the unused count after cleanup
|
||||
const newCount = await getUnusedLogosCount();
|
||||
setUnusedLogosCount(newCount);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
|
|
|
|||
117
frontend/src/hooks/useTablePreferences.jsx
Normal file
117
frontend/src/hooks/useTablePreferences.jsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const useTablePreferences = () => {
|
||||
// Initialize all preferences from localStorage
|
||||
const [headerPinned, setHeaderPinnedState] = useState(() => {
|
||||
try {
|
||||
const prefs = localStorage.getItem('table-preferences');
|
||||
if (prefs) {
|
||||
const parsed = JSON.parse(prefs);
|
||||
return parsed.headerPinned ?? false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading headerPinned from localStorage:', e);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const [tableSize, setTableSizeState] = useState(() => {
|
||||
try {
|
||||
// Check new location first
|
||||
const prefs = localStorage.getItem('table-preferences');
|
||||
if (prefs) {
|
||||
const parsed = JSON.parse(prefs);
|
||||
if (parsed.tableSize) {
|
||||
return parsed.tableSize;
|
||||
}
|
||||
}
|
||||
// Fallback to old location for migration
|
||||
const oldSize = localStorage.getItem('table-size');
|
||||
if (oldSize) {
|
||||
return JSON.parse(oldSize);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading tableSize from localStorage:', e);
|
||||
}
|
||||
return 'default';
|
||||
});
|
||||
|
||||
// Listen for changes from other components
|
||||
useEffect(() => {
|
||||
const handleCustomEvent = (e) => {
|
||||
if (
|
||||
e.detail.headerPinned !== undefined &&
|
||||
e.detail.headerPinned !== headerPinned
|
||||
) {
|
||||
setHeaderPinnedState(e.detail.headerPinned);
|
||||
}
|
||||
if (
|
||||
e.detail.tableSize !== undefined &&
|
||||
e.detail.tableSize !== tableSize
|
||||
) {
|
||||
setTableSizeState(e.detail.tableSize);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('table-preferences-changed', handleCustomEvent);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'table-preferences-changed',
|
||||
handleCustomEvent
|
||||
);
|
||||
}, [headerPinned, tableSize]);
|
||||
|
||||
// Function to update headerPinned and persist to localStorage
|
||||
const setHeaderPinned = useCallback((value) => {
|
||||
setHeaderPinnedState(value);
|
||||
|
||||
try {
|
||||
// Read current prefs, update headerPinned, and save back
|
||||
let prefs = {};
|
||||
const stored = localStorage.getItem('table-preferences');
|
||||
if (stored) {
|
||||
prefs = JSON.parse(stored);
|
||||
}
|
||||
prefs.headerPinned = value;
|
||||
localStorage.setItem('table-preferences', JSON.stringify(prefs));
|
||||
|
||||
// Dispatch custom event for same-page sync
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: value },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error saving headerPinned to localStorage:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to update tableSize and persist to localStorage
|
||||
const setTableSize = useCallback((value) => {
|
||||
setTableSizeState(value);
|
||||
|
||||
try {
|
||||
// Read current prefs, update tableSize, and save back
|
||||
let prefs = {};
|
||||
const stored = localStorage.getItem('table-preferences');
|
||||
if (stored) {
|
||||
prefs = JSON.parse(stored);
|
||||
}
|
||||
prefs.tableSize = value;
|
||||
localStorage.setItem('table-preferences', JSON.stringify(prefs));
|
||||
|
||||
// Dispatch custom event for same-page sync
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('table-preferences-changed', {
|
||||
detail: { tableSize: value },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error saving tableSize to localStorage:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { headerPinned, setHeaderPinned, tableSize, setTableSize };
|
||||
};
|
||||
|
||||
export default useTablePreferences;
|
||||
|
|
@ -118,7 +118,23 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
|
||||
.custom-multiselect .mantine-MultiSelect-input {
|
||||
min-height: 30px;
|
||||
/* Set a minimum height */
|
||||
max-height: 30px;
|
||||
/* Set max height */
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-pillsList {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-pill {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-pill span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -65,9 +65,7 @@ import {
|
|||
PROGRAM_HEIGHT,
|
||||
sortChannels,
|
||||
} from './guideUtils';
|
||||
import {
|
||||
getShowVideoUrl,
|
||||
} from '../utils/cards/RecordingCardUtils.js';
|
||||
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
add,
|
||||
convertToMs,
|
||||
|
|
@ -79,10 +77,12 @@ import {
|
|||
} from '../utils/dateTimeUtils.js';
|
||||
import GuideRow from '../components/GuideRow.jsx';
|
||||
import HourTimeline from '../components/HourTimeline';
|
||||
const ProgramRecordingModal = React.lazy(() =>
|
||||
import('../components/forms/ProgramRecordingModal'));
|
||||
const SeriesRecordingModal = React.lazy(() =>
|
||||
import('../components/forms/SeriesRecordingModal'));
|
||||
const ProgramRecordingModal = React.lazy(
|
||||
() => import('../components/forms/ProgramRecordingModal')
|
||||
);
|
||||
const SeriesRecordingModal = React.lazy(
|
||||
() => import('../components/forms/SeriesRecordingModal')
|
||||
);
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
[rowHeights]
|
||||
);
|
||||
|
||||
const [timeFormat, dateFormat] = useDateTimeFormat();
|
||||
const { timeFormat, dateFormat } = useDateTimeFormat();
|
||||
|
||||
// Format day label using relative terms when possible (Today, Tomorrow, etc)
|
||||
const formatDayLabel = useCallback(
|
||||
|
|
@ -774,9 +774,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
style={{
|
||||
cursor: 'pointer',
|
||||
zIndex: isExpanded ? 25 : 5,
|
||||
transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease',
|
||||
transition: isExpanded
|
||||
? 'height 0.2s ease, width 0.2s ease'
|
||||
: 'height 0.2s ease',
|
||||
}}
|
||||
pos='absolute'
|
||||
pos="absolute"
|
||||
left={leftPx + gapSize}
|
||||
top={0}
|
||||
w={isExpanded ? expandedWidthPx : widthPx}
|
||||
|
|
@ -806,7 +808,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
pos='relative'
|
||||
pos="relative"
|
||||
display={'flex'}
|
||||
p={isExpanded ? 12 : 8}
|
||||
c={isPast ? '#a0aec0' : '#fff'}
|
||||
|
|
@ -1007,7 +1009,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
c='#ffffff'
|
||||
c="#ffffff"
|
||||
ff={'Roboto, sans-serif'}
|
||||
onClick={handleClickOutside} // Close expanded program when clicking outside
|
||||
>
|
||||
|
|
@ -1016,9 +1018,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
direction="column"
|
||||
style={{
|
||||
zIndex: 1000,
|
||||
position: 'sticky'
|
||||
position: 'sticky',
|
||||
}}
|
||||
c='#ffffff'
|
||||
c="#ffffff"
|
||||
p={'12px 20px'}
|
||||
top={0}
|
||||
>
|
||||
|
|
@ -1101,7 +1103,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
backgroundColor: '#245043',
|
||||
}}
|
||||
bd={'1px solid #3BA882'}
|
||||
color='#FFFFFF'
|
||||
color="#FFFFFF"
|
||||
>
|
||||
Series Rules
|
||||
</Button>
|
||||
|
|
@ -1125,7 +1127,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
<Box
|
||||
style={{
|
||||
zIndex: 100,
|
||||
position: 'sticky'
|
||||
position: 'sticky',
|
||||
}}
|
||||
display={'flex'}
|
||||
top={0}
|
||||
|
|
@ -1142,7 +1144,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
w={CHANNEL_WIDTH}
|
||||
miw={CHANNEL_WIDTH}
|
||||
h={'40px'}
|
||||
pos='sticky'
|
||||
pos="sticky"
|
||||
left={0}
|
||||
/>
|
||||
|
||||
|
|
@ -1152,7 +1154,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
pos='relative'
|
||||
pos="relative"
|
||||
>
|
||||
<Box
|
||||
ref={timelineRef}
|
||||
|
|
@ -1160,7 +1162,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
pos='relative'
|
||||
pos="relative"
|
||||
onScroll={handleTimelineScroll}
|
||||
onWheel={handleTimelineWheel} // Add wheel event handler
|
||||
>
|
||||
|
|
@ -1190,7 +1192,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
pos='relative'
|
||||
pos="relative"
|
||||
>
|
||||
<LoadingOverlay visible={isLoading || isProgramsLoading} />
|
||||
{nowPosition >= 0 && (
|
||||
|
|
@ -1200,7 +1202,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
zIndex: 15,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
pos='absolute'
|
||||
pos="absolute"
|
||||
left={nowPosition + CHANNEL_WIDTH - guideScrollLeft}
|
||||
top={0}
|
||||
bottom={0}
|
||||
|
|
@ -1225,7 +1227,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{GuideRow}
|
||||
</VariableSizeList>
|
||||
) : (
|
||||
<Box p={'30px'} ta='center' color='#a0aec0'>
|
||||
<Box p={'30px'} ta="center" color="#a0aec0">
|
||||
<Text size="lg">No channels match your filters</Text>
|
||||
<Button variant="subtle" onClick={clearFilters} mt={10}>
|
||||
Clear Filters
|
||||
|
|
@ -1245,8 +1247,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
recording={recordingForProgram}
|
||||
existingRuleMode={existingRuleMode}
|
||||
onRecordOne={() => recordOne(recordChoiceProgram)}
|
||||
onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')}
|
||||
onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')}
|
||||
onRecordSeriesAll={() =>
|
||||
saveSeriesRule(recordChoiceProgram, 'all')
|
||||
}
|
||||
onRecordSeriesNew={() =>
|
||||
saveSeriesRule(recordChoiceProgram, 'new')
|
||||
}
|
||||
onExistingRuleModeChange={setExistingRuleMode}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Button, Group, LoadingOverlay, NumberInput, Text, Title, } from '@mantine/core';
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import useLogosStore from '../store/logos';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
|
|
@ -10,22 +25,27 @@ import {
|
|||
fetchActiveChannelStats,
|
||||
getClientStats,
|
||||
getCombinedConnections,
|
||||
getCurrentPrograms,
|
||||
getStatsByChannelId,
|
||||
getVODStats,
|
||||
stopChannel,
|
||||
stopClient,
|
||||
stopVODClient,
|
||||
} from '../utils/pages/StatsUtils.js';
|
||||
const VodConnectionCard = React.lazy(() =>
|
||||
import('../components/cards/VodConnectionCard.jsx'));
|
||||
const StreamConnectionCard = React.lazy(() =>
|
||||
import('../components/cards/StreamConnectionCard.jsx'));
|
||||
const VodConnectionCard = React.lazy(
|
||||
() => import('../components/cards/VodConnectionCard.jsx')
|
||||
);
|
||||
const StreamConnectionCard = React.lazy(
|
||||
() => import('../components/cards/StreamConnectionCard.jsx')
|
||||
);
|
||||
|
||||
const Connections = ({
|
||||
combinedConnections,
|
||||
clients,
|
||||
channelsByUUID,
|
||||
channels,
|
||||
handleStopVODClient,
|
||||
currentPrograms,
|
||||
}) => {
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
|
||||
|
|
@ -55,6 +75,8 @@ const Connections = ({
|
|||
stopChannel={stopChannel}
|
||||
logos={logos}
|
||||
channelsByUUID={channelsByUUID}
|
||||
channels={channels}
|
||||
currentProgram={currentPrograms[connection.data.channel_id]}
|
||||
/>
|
||||
);
|
||||
} else if (connection.type === 'vod') {
|
||||
|
|
@ -84,6 +106,20 @@ const StatsPage = () => {
|
|||
const [vodConnections, setVodConnections] = useState([]);
|
||||
const [channelHistory, setChannelHistory] = useState({});
|
||||
const [isPollingActive, setIsPollingActive] = useState(false);
|
||||
const [currentPrograms, setCurrentPrograms] = useState({});
|
||||
|
||||
// Use refs to hold latest values without triggering effects
|
||||
const channelHistoryRef = useRef(channelHistory);
|
||||
const channelsByUUIDRef = useRef(channelsByUUID);
|
||||
|
||||
// Update refs when values change
|
||||
useEffect(() => {
|
||||
channelHistoryRef.current = channelHistory;
|
||||
}, [channelHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
channelsByUUIDRef.current = channelsByUUID;
|
||||
}, [channelsByUUID]);
|
||||
|
||||
// Use localStorage for stats refresh interval (in seconds)
|
||||
const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage(
|
||||
|
|
@ -191,7 +227,13 @@ const StatsPage = () => {
|
|||
// Use functional update to access previous state without dependency
|
||||
setChannelHistory((prevChannelHistory) => {
|
||||
// Create a completely new object based only on current channel stats
|
||||
const stats = getStatsByChannelId(channelStats, prevChannelHistory, channelsByUUID, channels, streamProfiles);
|
||||
const stats = getStatsByChannelId(
|
||||
channelStats,
|
||||
prevChannelHistory,
|
||||
channelsByUUID,
|
||||
channels,
|
||||
streamProfiles
|
||||
);
|
||||
|
||||
console.log('Processed active channels:', stats);
|
||||
|
||||
|
|
@ -202,6 +244,64 @@ const StatsPage = () => {
|
|||
});
|
||||
}, [channelStats, channels, channelsByUUID, streamProfiles]);
|
||||
|
||||
// Track which channel IDs are active (only changes when channels start/stop, not on stats updates)
|
||||
const activeChannelIds = useMemo(() => {
|
||||
return Object.keys(channelHistory).sort().join(',');
|
||||
}, [channelHistory]);
|
||||
|
||||
// Smart polling for current programs - only fetch when active channels change
|
||||
useEffect(() => {
|
||||
// Skip if no active channels
|
||||
if (!activeChannelIds) {
|
||||
setCurrentPrograms({});
|
||||
return;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
// Use refs to get latest values without adding dependencies
|
||||
const programs = await getCurrentPrograms(
|
||||
channelHistoryRef.current,
|
||||
channelsByUUIDRef.current
|
||||
);
|
||||
setCurrentPrograms(programs);
|
||||
|
||||
// Schedule next fetch based on nearest program end time
|
||||
if (programs && Object.keys(programs).length > 0) {
|
||||
const now = new Date();
|
||||
let nearestEndTime = null;
|
||||
|
||||
Object.values(programs).forEach((program) => {
|
||||
if (program && program.end_time) {
|
||||
const endTime = new Date(program.end_time);
|
||||
if (
|
||||
endTime > now &&
|
||||
(!nearestEndTime || endTime < nearestEndTime)
|
||||
) {
|
||||
nearestEndTime = endTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (nearestEndTime) {
|
||||
const timeUntilChange = nearestEndTime.getTime() - now.getTime();
|
||||
const fetchDelay = Math.max(timeUntilChange + 5000, 0);
|
||||
|
||||
timer = setTimeout(fetchPrograms, fetchDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchPrograms();
|
||||
|
||||
// Cleanup timer on unmount or when active channels change
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeChannelIds]); // Only depend on activeChannelIds
|
||||
|
||||
// Combine active streams and VOD connections into a single mixed list
|
||||
const combinedConnections = useMemo(() => {
|
||||
return getCombinedConnections(channelHistory, vodConnections);
|
||||
|
|
@ -216,11 +316,12 @@ const StatsPage = () => {
|
|||
<Title order={3}>Active Connections</Title>
|
||||
<Group align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{channelHistoryLength} {
|
||||
channelHistoryLength !== 1 ? 'streams' : 'stream'
|
||||
} • {vodConnectionsCount} {
|
||||
vodConnectionsCount !== 1 ? 'VOD connections' : 'VOD connection'
|
||||
}
|
||||
{channelHistoryLength}{' '}
|
||||
{channelHistoryLength !== 1 ? 'streams' : 'stream'} •{' '}
|
||||
{vodConnectionsCount}{' '}
|
||||
{vodConnectionsCount !== 1
|
||||
? 'VOD connections'
|
||||
: 'VOD connection'}
|
||||
</Text>
|
||||
<Group align="center" gap="xs">
|
||||
<Text size="sm">Refresh Interval (seconds):</Text>
|
||||
|
|
@ -273,7 +374,9 @@ const StatsPage = () => {
|
|||
combinedConnections={combinedConnections}
|
||||
clients={clients}
|
||||
channelsByUUID={channelsByUUID}
|
||||
channels={channels}
|
||||
handleStopVODClient={handleStopVODClient}
|
||||
currentPrograms={currentPrograms}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import Guide from '../Guide';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
|
@ -82,8 +77,22 @@ vi.mock('@mantine/core', async () => {
|
|||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
|
||||
<button onClick={onClick} disabled={disabled} data-variant={variant} data-size={size} data-color={color}>
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
leftSection,
|
||||
variant,
|
||||
size,
|
||||
color,
|
||||
disabled,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
|
|
@ -91,7 +100,12 @@ vi.mock('@mantine/core', async () => {
|
|||
TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
|
||||
<div>
|
||||
{icon}
|
||||
<input type="text" value={value} onChange={onChange} placeholder={placeholder} />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -111,7 +125,12 @@ vi.mock('@mantine/core', async () => {
|
|||
</select>
|
||||
),
|
||||
ActionIcon: ({ children, onClick, variant, size, color }) => (
|
||||
<button onClick={onClick} data-variant={variant} data-size={size} data-color={color}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -122,21 +141,23 @@ vi.mock('@mantine/core', async () => {
|
|||
vi.mock('react-window', () => ({
|
||||
VariableSizeList: ({ children, itemData, itemCount }) => (
|
||||
<div data-testid="variable-size-list">
|
||||
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
|
||||
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) => (
|
||||
<div key={i}>
|
||||
{children({
|
||||
index: i,
|
||||
style: {},
|
||||
data: itemData.filteredChannels[i]
|
||||
data: itemData.filteredChannels[i],
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/GuideRow', () => ({
|
||||
default: ({ data }) => <div data-testid="guide-row">GuideRow for {data?.name}</div>,
|
||||
default: ({ data }) => (
|
||||
<div data-testid="guide-row">GuideRow for {data?.name}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../components/HourTimeline', () => ({
|
||||
default: ({ hourTimeline }) => (
|
||||
|
|
@ -184,7 +205,9 @@ vi.mock('../guideUtils', async () => {
|
|||
};
|
||||
});
|
||||
vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
|
||||
const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js');
|
||||
const actual = await vi.importActual(
|
||||
'../../utils/cards/RecordingCardUtils.js'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getShowVideoUrl: vi.fn(),
|
||||
|
|
@ -262,7 +285,9 @@ describe('Guide', () => {
|
|||
});
|
||||
|
||||
useEPGsStore.mockImplementation((selector) =>
|
||||
selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} }
|
||||
selector
|
||||
? selector({ tvgsById: {}, epgs: {} })
|
||||
: { tvgsById: {}, epgs: {} }
|
||||
);
|
||||
|
||||
useSettingsStore.mockReturnValue('production');
|
||||
|
|
@ -274,13 +299,18 @@ describe('Guide', () => {
|
|||
if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM';
|
||||
return '12:00 PM';
|
||||
});
|
||||
dateTimeUtils.initializeTime.mockImplementation(date => date || now);
|
||||
dateTimeUtils.initializeTime.mockImplementation((date) => date || now);
|
||||
dateTimeUtils.startOfDay.mockReturnValue(now.startOf('day'));
|
||||
dateTimeUtils.add.mockImplementation((date, amount, unit) =>
|
||||
dayjs(date).add(amount, unit)
|
||||
);
|
||||
dateTimeUtils.convertToMs.mockImplementation(date => dayjs(date).valueOf());
|
||||
dateTimeUtils.useDateTimeFormat.mockReturnValue(['12h', 'MM/DD/YYYY']);
|
||||
dateTimeUtils.convertToMs.mockImplementation((date) =>
|
||||
dayjs(date).valueOf()
|
||||
);
|
||||
dateTimeUtils.useDateTimeFormat.mockReturnValue({
|
||||
timeFormat: '12h',
|
||||
dateFormat: 'MM/DD/YYYY',
|
||||
});
|
||||
|
||||
guideUtils.fetchPrograms.mockResolvedValue([
|
||||
{
|
||||
|
|
@ -300,8 +330,8 @@ describe('Guide', () => {
|
|||
]);
|
||||
|
||||
guideUtils.fetchRules.mockResolvedValue([]);
|
||||
guideUtils.filterGuideChannels.mockImplementation(
|
||||
(channels) => Object.values(channels)
|
||||
guideUtils.filterGuideChannels.mockImplementation((channels) =>
|
||||
Object.values(channels)
|
||||
);
|
||||
guideUtils.createRecording.mockResolvedValue(undefined);
|
||||
guideUtils.createSeriesRule.mockResolvedValue(undefined);
|
||||
|
|
@ -348,7 +378,9 @@ describe('Guide', () => {
|
|||
render(<Guide />);
|
||||
|
||||
// await waitFor(() => {
|
||||
expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No channels match your filters')
|
||||
).toBeInTheDocument();
|
||||
// });
|
||||
});
|
||||
|
||||
|
|
@ -356,7 +388,7 @@ describe('Guide', () => {
|
|||
render(<Guide />);
|
||||
|
||||
// await waitFor(() => {
|
||||
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
|
@ -394,7 +426,8 @@ describe('Guide', () => {
|
|||
const user = userEvent.setup({ delay: null });
|
||||
render(<Guide />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText('Search channels...');
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('Search channels...');
|
||||
await user.type(searchInput, 'News');
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -457,7 +490,8 @@ describe('Guide', () => {
|
|||
render(<Guide />);
|
||||
|
||||
// Set some filters
|
||||
const searchInput = await screen.findByPlaceholderText('Search channels...');
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('Search channels...');
|
||||
await user.type(searchInput, 'Test');
|
||||
|
||||
// Clear them
|
||||
|
|
@ -479,7 +513,9 @@ describe('Guide', () => {
|
|||
await user.click(rulesButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('series-recording-modal')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -538,7 +574,12 @@ describe('Guide', () => {
|
|||
describe('Error Handling', () => {
|
||||
it('shows notification when no channels are available', async () => {
|
||||
useChannelsStore.mockImplementation((selector) => {
|
||||
const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} };
|
||||
const state = {
|
||||
channels: {},
|
||||
recordings: [],
|
||||
channelGroups: {},
|
||||
profiles: {},
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
|
|
@ -616,4 +657,4 @@ describe('Guide', () => {
|
|||
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import useChannelsStore from '../../store/channels';
|
|||
import useLogosStore from '../../store/logos';
|
||||
import {
|
||||
fetchActiveChannelStats,
|
||||
getCurrentPrograms,
|
||||
getClientStats,
|
||||
getCombinedConnections,
|
||||
getStatsByChannelId,
|
||||
|
|
@ -30,11 +31,11 @@ vi.mock('../../store/streamProfiles');
|
|||
vi.mock('../../hooks/useLocalStorage');
|
||||
|
||||
vi.mock('../../components/SystemEvents', () => ({
|
||||
default: () => <div data-testid="system-events">SystemEvents</div>
|
||||
default: () => <div data-testid="system-events">SystemEvents</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ErrorBoundary.jsx', () => ({
|
||||
default: ({ children }) => <div data-testid="error-boundary">{children}</div>
|
||||
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
|
||||
|
|
@ -92,6 +93,7 @@ vi.mock('../../utils/pages/StatsUtils', () => {
|
|||
return {
|
||||
fetchActiveChannelStats: vi.fn(),
|
||||
getVODStats: vi.fn(),
|
||||
getCurrentPrograms: vi.fn(),
|
||||
getClientStats: vi.fn(),
|
||||
getCombinedConnections: vi.fn(),
|
||||
getStatsByChannelId: vi.fn(),
|
||||
|
|
@ -112,9 +114,7 @@ describe('StatsPage', () => {
|
|||
'channel-2': mockChannels[1],
|
||||
};
|
||||
|
||||
const mockStreamProfiles = [
|
||||
{ id: 1, name: 'Profile 1' },
|
||||
];
|
||||
const mockStreamProfiles = [{ id: 1, name: 'Profile 1' }];
|
||||
|
||||
const mockLogos = {
|
||||
'logo-1': 'logo-url-1',
|
||||
|
|
@ -131,9 +131,7 @@ describe('StatsPage', () => {
|
|||
vod_connections: [
|
||||
{
|
||||
content_uuid: 'vod-1',
|
||||
connections: [
|
||||
{ client_id: 'client-1', ip: '192.168.1.1' },
|
||||
],
|
||||
connections: [{ client_id: 'client-1', ip: '192.168.1.1' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -152,7 +150,11 @@ describe('StatsPage', () => {
|
|||
const mockCombinedConnections = [
|
||||
{ id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
|
||||
{ id: 2, type: 'stream', data: { id: 2, uuid: 'channel-2' } },
|
||||
{ id: 3, type: 'vod', data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] } },
|
||||
{
|
||||
id: 3,
|
||||
type: 'vod',
|
||||
data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] },
|
||||
},
|
||||
];
|
||||
|
||||
let mockSetChannelStats;
|
||||
|
|
@ -194,6 +196,7 @@ describe('StatsPage', () => {
|
|||
// Setup API mocks
|
||||
fetchActiveChannelStats.mockResolvedValue(mockChannelStats);
|
||||
getVODStats.mockResolvedValue(mockVODStats);
|
||||
getCurrentPrograms.mockResolvedValue({});
|
||||
getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory);
|
||||
getClientStats.mockReturnValue(mockClients);
|
||||
getCombinedConnections.mockReturnValue(mockCombinedConnections);
|
||||
|
|
@ -206,7 +209,7 @@ describe('StatsPage', () => {
|
|||
describe('Initial Rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(<StatsPage />);
|
||||
await screen.findByText('Active Connections')
|
||||
await screen.findByText('Active Connections');
|
||||
});
|
||||
|
||||
it('fetches initial stats on mount', async () => {
|
||||
|
|
@ -229,7 +232,7 @@ describe('StatsPage', () => {
|
|||
|
||||
it('renders SystemEvents component', async () => {
|
||||
render(<StatsPage />);
|
||||
await screen.findByTestId('system-events')
|
||||
await screen.findByTestId('system-events');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -266,7 +269,7 @@ describe('StatsPage', () => {
|
|||
useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
|
||||
render(<StatsPage />);
|
||||
|
||||
await screen.findByText('Refreshing disabled')
|
||||
await screen.findByText('Refreshing disabled');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -348,8 +351,12 @@ describe('StatsPage', () => {
|
|||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('stream-connection-card-channel-1')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('stream-connection-card-channel-2')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -357,7 +364,9 @@ describe('StatsPage', () => {
|
|||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('vod-connection-card-vod-1')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -376,7 +385,9 @@ describe('StatsPage', () => {
|
|||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('stop-vod-client-client-1')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const stopButton = screen.getByTestId('stop-vod-client-client-1');
|
||||
|
|
@ -422,14 +433,18 @@ describe('StatsPage', () => {
|
|||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory);
|
||||
expect(getClientStats).toHaveBeenCalledWith(
|
||||
mockProcessedChannelHistory
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles fetchActiveChannelStats error gracefully', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
fetchActiveChannelStats.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
render(<StatsPage />);
|
||||
|
|
@ -445,7 +460,9 @@ describe('StatsPage', () => {
|
|||
});
|
||||
|
||||
it('handles getVODStats error gracefully', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
getVODStats.mockRejectedValue(new Error('VOD API Error'));
|
||||
|
||||
render(<StatsPage />);
|
||||
|
|
@ -491,4 +508,4 @@ describe('StatsPage', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const isTokenExpired = (expirationTime) => {
|
|||
const useAuthStore = create((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
isInitializing: false,
|
||||
needsSuperuser: false,
|
||||
user: {
|
||||
username: '',
|
||||
|
|
@ -37,20 +38,28 @@ const useAuthStore = create((set, get) => ({
|
|||
setUser: (user) => set({ user }),
|
||||
|
||||
initData: async () => {
|
||||
const user = await API.me();
|
||||
if (user.user_level <= USER_LEVELS.STREAMER) {
|
||||
throw new Error('Unauthorized');
|
||||
// Prevent multiple simultaneous initData calls
|
||||
if (get().isInitializing || get().isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ user, isAuthenticated: true });
|
||||
|
||||
// Ensure settings are loaded first
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
set({ isInitializing: true });
|
||||
|
||||
try {
|
||||
// Only after settings are loaded, fetch the essential data
|
||||
const user = await API.me();
|
||||
if (user.user_level <= USER_LEVELS.STREAMER) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
set({ user });
|
||||
|
||||
// Ensure settings are loaded first
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
|
||||
// Fetch essential data needed for initial render
|
||||
// Note: fetchChannels() is intentionally NOT awaited here - it's slow (~3s)
|
||||
// and only needed for delete modal details. It loads in background after UI renders.
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
|
|
@ -64,10 +73,23 @@ const useAuthStore = create((set, get) => ({
|
|||
await Promise.all([useUsersStore.getState().fetchUsers()]);
|
||||
}
|
||||
|
||||
// Only set isAuthenticated and isInitialized AFTER essential data is loaded
|
||||
// This prevents routes from rendering before data is ready
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
isInitializing: false,
|
||||
});
|
||||
|
||||
// Load channels data in background (not blocking) - needed for delete modal details
|
||||
useChannelsStore.getState().fetchChannels();
|
||||
|
||||
// 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);
|
||||
set({ isInitializing: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -118,7 +140,7 @@ const useAuthStore = create((set, get) => ({
|
|||
// Action to refresh the token
|
||||
getRefreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return false; // Add explicit return here
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const data = await api.refreshToken(refreshToken);
|
||||
|
|
@ -126,18 +148,17 @@ const useAuthStore = create((set, get) => ({
|
|||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
localStorage.setItem('accessToken', data.access);
|
||||
localStorage.setItem('tokenExpiration', decodeToken(data.access));
|
||||
|
||||
return data.access;
|
||||
}
|
||||
return false; // Add explicit return for when data.access is not available
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
await get().logout();
|
||||
return false; // Add explicit return after error
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -156,6 +177,8 @@ const useAuthStore = create((set, get) => ({
|
|||
refreshToken: null,
|
||||
tokenExpiration: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
isInitializing: false,
|
||||
user: null,
|
||||
});
|
||||
localStorage.removeItem('accessToken');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
|
|||
},
|
||||
selectedChannelIds: [],
|
||||
allQueryIds: [],
|
||||
isUnlocked: false,
|
||||
|
||||
queryChannels: ({ results, count }, params) => {
|
||||
set((state) => {
|
||||
|
|
@ -54,6 +55,18 @@ const useChannelsTableStore = create((set, get) => ({
|
|||
sorting,
|
||||
}));
|
||||
},
|
||||
|
||||
setIsUnlocked: (isUnlocked) => {
|
||||
set({ isUnlocked });
|
||||
},
|
||||
|
||||
updateChannel: (updatedChannel) => {
|
||||
set((state) => ({
|
||||
channels: state.channels.map((channel) =>
|
||||
channel.id === updatedChannel.id ? updatedChannel : channel
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
export default useChannelsTableStore;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const useSettingsStore = create((set) => ({
|
||||
const useSettingsStore = create((set, get) => ({
|
||||
settings: {},
|
||||
environment: {
|
||||
// Add default values for environment settings
|
||||
|
|
@ -10,15 +10,27 @@ const useSettingsStore = create((set) => ({
|
|||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
version: {
|
||||
version: '',
|
||||
timestamp: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchSettings: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
const env = await api.getEnvironmentSettings();
|
||||
set({
|
||||
// Only fetch version if not already loaded (may have been fetched by Login/Superuser form)
|
||||
const currentVersion = get().version;
|
||||
const needsVersion = !currentVersion.version;
|
||||
|
||||
const [settings, env, versionData] = await Promise.all([
|
||||
api.getSettings(),
|
||||
api.getEnvironmentSettings(),
|
||||
needsVersion ? api.getVersion() : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const newState = {
|
||||
settings: settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting;
|
||||
return acc;
|
||||
|
|
@ -30,12 +42,42 @@ const useSettingsStore = create((set) => ({
|
|||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Only update version if we fetched it
|
||||
if (versionData) {
|
||||
newState.version = {
|
||||
version: versionData?.version || '',
|
||||
timestamp: versionData?.timestamp || null,
|
||||
};
|
||||
}
|
||||
|
||||
set(newState);
|
||||
} catch (error) {
|
||||
set({ error: 'Failed to load settings.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch version independently (for unauthenticated pages like Login)
|
||||
fetchVersion: async () => {
|
||||
// Skip if already loaded
|
||||
if (get().version.version) {
|
||||
return get().version;
|
||||
}
|
||||
try {
|
||||
const versionData = await api.getVersion();
|
||||
const version = {
|
||||
version: versionData?.version || '',
|
||||
timestamp: versionData?.timestamp || null,
|
||||
};
|
||||
set({ version });
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version:', error);
|
||||
return get().version;
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: (setting) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [setting.key]: setting },
|
||||
|
|
|
|||
56
frontend/src/store/streamsTable.jsx
Normal file
56
frontend/src/store/streamsTable.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
const useStreamsTableStore = create((set) => ({
|
||||
streams: [],
|
||||
pageCount: 0,
|
||||
totalCount: 0,
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize:
|
||||
JSON.parse(localStorage.getItem('streams-page-size')) || 50,
|
||||
},
|
||||
selectedStreamIds: [],
|
||||
allQueryIds: [],
|
||||
lastQueryParams: null,
|
||||
|
||||
queryStreams: ({ results, count }, params) => {
|
||||
set(() => ({
|
||||
streams: results,
|
||||
totalCount: count,
|
||||
pageCount: Math.ceil(count / params.get('page_size')),
|
||||
}));
|
||||
},
|
||||
|
||||
setAllQueryIds: (allQueryIds) => {
|
||||
set(() => ({
|
||||
allQueryIds,
|
||||
}));
|
||||
},
|
||||
|
||||
setSelectedStreamIds: (selectedStreamIds) => {
|
||||
set(() => ({
|
||||
selectedStreamIds,
|
||||
}));
|
||||
},
|
||||
|
||||
setPagination: (pagination) => {
|
||||
set(() => ({
|
||||
pagination,
|
||||
}));
|
||||
},
|
||||
|
||||
setSorting: (sorting) => {
|
||||
set(() => ({
|
||||
sorting,
|
||||
}));
|
||||
},
|
||||
|
||||
setLastQueryParams: (lastQueryParams) => {
|
||||
set(() => ({
|
||||
lastQueryParams,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
export default useStreamsTableStore;
|
||||
|
|
@ -116,6 +116,21 @@ const useVODLogosStore = create((set) => ({
|
|||
}
|
||||
},
|
||||
|
||||
getUnusedLogosCount: async () => {
|
||||
try {
|
||||
const response = await api.getVODLogos({
|
||||
used: 'false',
|
||||
page_size: 1, // Fetch only 1 item to minimize data transfer
|
||||
});
|
||||
|
||||
// Return the count from the paginated response
|
||||
return response.count || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unused logos count:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearVODLogos: () => {
|
||||
set({
|
||||
vodLogos: {},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export default {
|
||||
Limiter: (n, list) => {
|
||||
|
|
@ -40,13 +40,25 @@ export default {
|
|||
// Custom debounce hook
|
||||
export function useDebounce(value, delay = 500, callback = null) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
const isFirstRender = useRef(true);
|
||||
const previousValueRef = useRef(JSON.stringify(value));
|
||||
|
||||
useEffect(() => {
|
||||
const currentValueStr = JSON.stringify(value);
|
||||
|
||||
// Skip if value hasn't actually changed (prevents unnecessary state updates)
|
||||
if (previousValueRef.current === currentValueStr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
if (callback) {
|
||||
// Only fire callback if not the first render
|
||||
if (callback && !isFirstRender.current) {
|
||||
callback();
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
previousValueRef.current = currentValueStr;
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
|
||||
|
|
|
|||
|
|
@ -336,7 +336,8 @@ describe('dateTimeUtils', () => {
|
|||
|
||||
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
|
||||
|
||||
expect(result.current).toEqual(['h:mma', 'MMM D']);
|
||||
expect(result.current.timeFormat).toBe('h:mma');
|
||||
expect(result.current.dateFormat).toBe('MMM D');
|
||||
});
|
||||
|
||||
it('should return 24h format when set', () => {
|
||||
|
|
@ -344,7 +345,7 @@ describe('dateTimeUtils', () => {
|
|||
|
||||
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
|
||||
|
||||
expect(result.current[0]).toBe('HH:mm');
|
||||
expect(result.current.timeFormat).toBe('HH:mm');
|
||||
});
|
||||
|
||||
it('should return dmy date format when set', () => {
|
||||
|
|
@ -352,7 +353,7 @@ describe('dateTimeUtils', () => {
|
|||
|
||||
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
|
||||
|
||||
expect(result.current[1]).toBe('D MMM');
|
||||
expect(result.current.dateFormat).toBe('D MMM');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -68,19 +68,19 @@ export const switchStream = (channel, streamId) => {
|
|||
return API.switchStream(channel.channel_id, streamId);
|
||||
};
|
||||
|
||||
export const connectedAccessor = (dateFormat) => {
|
||||
export const connectedAccessor = (fullDateTimeFormat) => {
|
||||
return (row) => {
|
||||
// Check for connected_since (which is seconds since connection)
|
||||
if (row.connected_since) {
|
||||
// Calculate the actual connection time by subtracting the seconds from current time
|
||||
const connectedTime = subtract(getNow(), row.connected_since, 'second');
|
||||
return format(connectedTime, `${dateFormat} HH:mm:ss`);
|
||||
return format(connectedTime, fullDateTimeFormat);
|
||||
}
|
||||
|
||||
// Fallback to connected_at if it exists
|
||||
if (row.connected_at) {
|
||||
const connectedTime = initializeTime(row.connected_at * 1000);
|
||||
return format(connectedTime, `${dateFormat} HH:mm:ss`);
|
||||
return format(connectedTime, fullDateTimeFormat);
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
|
|
|
|||
|
|
@ -117,9 +117,9 @@ export const calculateConnectionDuration = (connection) => {
|
|||
return 'Unknown duration';
|
||||
}
|
||||
|
||||
export const calculateConnectionStartTime = (connection, dateFormat) => {
|
||||
export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
|
||||
if (connection.connected_at) {
|
||||
return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`);
|
||||
return format(connection.connected_at * 1000, fullDateTimeFormat);
|
||||
}
|
||||
|
||||
// Fallback: calculate from client_id timestamp
|
||||
|
|
@ -128,7 +128,7 @@ export const calculateConnectionStartTime = (connection, dateFormat) => {
|
|||
const parts = connection.client_id.split('_');
|
||||
if (parts.length >= 2) {
|
||||
const clientStartTime = parseInt(parts[1]);
|
||||
return format(clientStartTime, `${dateFormat} HH:mm:ss`);
|
||||
return format(clientStartTime, fullDateTimeFormat);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
|
|
|
|||
|
|
@ -167,11 +167,11 @@ describe('StreamConnectionCardUtils', () => {
|
|||
dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
|
||||
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
|
||||
|
||||
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
|
||||
const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss');
|
||||
const result = accessor({ connected_since: 7200 });
|
||||
|
||||
expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY HH:mm:ss');
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss');
|
||||
expect(result).toBe('01/01/2024 10:00:00');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -279,9 +279,9 @@ describe('VodConnectionCardUtils', () => {
|
|||
dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
|
||||
|
||||
const connection = { connected_at: 1705329000 };
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
|
||||
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY HH:mm:ss');
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss');
|
||||
expect(result).toBe('01/15/2024 14:30:00');
|
||||
});
|
||||
|
||||
|
|
@ -289,15 +289,15 @@ describe('VodConnectionCardUtils', () => {
|
|||
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
|
||||
|
||||
const connection = { client_id: 'vod_1705323600000_abc' };
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
|
||||
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY HH:mm:ss');
|
||||
expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss');
|
||||
expect(result).toBe('01/15/2024 13:00:00');
|
||||
});
|
||||
|
||||
it('should return Unknown when no timestamp data available', () => {
|
||||
const connection = {};
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
|
||||
const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
|
||||
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export const useTimeHelpers = () => {
|
|||
|
||||
const toUserTime = useCallback(
|
||||
(value) => {
|
||||
if (!value) return dayjs.invalid();
|
||||
if (!value) return dayjs(null);
|
||||
try {
|
||||
return initializeTime(value).tz(timeZone);
|
||||
} catch (error) {
|
||||
|
|
@ -112,7 +112,21 @@ export const useDateTimeFormat = () => {
|
|||
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
|
||||
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
|
||||
|
||||
return [timeFormat, dateFormat];
|
||||
// Full format strings for detailed date-time displays
|
||||
const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
|
||||
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
|
||||
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
|
||||
|
||||
return {
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
fullDateFormat,
|
||||
fullTimeFormat,
|
||||
fullDateTimeFormat,
|
||||
// Also return raw settings for cases that need them
|
||||
timeFormatSetting,
|
||||
dateFormatSetting,
|
||||
};
|
||||
};
|
||||
|
||||
export const toTimeString = (value) => {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ describe('UiSettingsFormUtils', () => {
|
|||
it('should update existing setting when id is present', async () => {
|
||||
const tzValue = 'America/New_York';
|
||||
const settings = {
|
||||
'system-time-zone': {
|
||||
'system_settings': {
|
||||
id: 123,
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'UTC'
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'UTC' }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -29,9 +29,9 @@ describe('UiSettingsFormUtils', () => {
|
|||
expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
|
||||
id: 123,
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'America/New_York'
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'America/New_York' }
|
||||
});
|
||||
expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -39,10 +39,10 @@ describe('UiSettingsFormUtils', () => {
|
|||
it('should create new setting when existing setting has no id', async () => {
|
||||
const tzValue = 'Europe/London';
|
||||
const settings = {
|
||||
'system-time-zone': {
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'UTC'
|
||||
'system_settings': {
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'UTC' }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -50,14 +50,14 @@ describe('UiSettingsFormUtils', () => {
|
|||
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'Europe/London'
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'Europe/London' }
|
||||
});
|
||||
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new setting when system-time-zone does not exist', async () => {
|
||||
it('should create new setting when system_settings does not exist', async () => {
|
||||
const tzValue = 'Asia/Tokyo';
|
||||
const settings = {};
|
||||
|
||||
|
|
@ -65,26 +65,26 @@ describe('UiSettingsFormUtils', () => {
|
|||
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'Asia/Tokyo'
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'Asia/Tokyo' }
|
||||
});
|
||||
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new setting when system-time-zone is null', async () => {
|
||||
it('should create new setting when system_settings is null', async () => {
|
||||
const tzValue = 'Pacific/Auckland';
|
||||
const settings = {
|
||||
'system-time-zone': null
|
||||
'system_settings': null
|
||||
};
|
||||
|
||||
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
|
||||
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
|
||||
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'Pacific/Auckland'
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'Pacific/Auckland' }
|
||||
});
|
||||
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -92,10 +92,10 @@ describe('UiSettingsFormUtils', () => {
|
|||
it('should create new setting when id is undefined', async () => {
|
||||
const tzValue = 'America/Los_Angeles';
|
||||
const settings = {
|
||||
'system-time-zone': {
|
||||
'system_settings': {
|
||||
id: undefined,
|
||||
key: 'system-time-zone',
|
||||
value: 'UTC'
|
||||
key: 'system_settings',
|
||||
value: { time_zone: 'UTC' }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -108,11 +108,11 @@ describe('UiSettingsFormUtils', () => {
|
|||
it('should preserve existing properties when updating', async () => {
|
||||
const tzValue = 'UTC';
|
||||
const settings = {
|
||||
'system-time-zone': {
|
||||
'system_settings': {
|
||||
id: 456,
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'America/New_York',
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'America/New_York', some_other_setting: 'value' },
|
||||
extraProp: 'should be preserved'
|
||||
}
|
||||
};
|
||||
|
|
@ -121,9 +121,9 @@ describe('UiSettingsFormUtils', () => {
|
|||
|
||||
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
|
||||
id: 456,
|
||||
key: 'system-time-zone',
|
||||
name: 'System Time Zone',
|
||||
value: 'UTC',
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'UTC', some_other_setting: 'value' },
|
||||
extraProp: 'should be preserved'
|
||||
});
|
||||
});
|
||||
|
|
@ -131,8 +131,11 @@ describe('UiSettingsFormUtils', () => {
|
|||
it('should handle empty string timezone value', async () => {
|
||||
const tzValue = '';
|
||||
const settings = {
|
||||
'system-time-zone': {
|
||||
id: 789
|
||||
'system_settings': {
|
||||
id: 789,
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: 'America/New_York' }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -140,7 +143,9 @@ describe('UiSettingsFormUtils', () => {
|
|||
|
||||
expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
|
||||
id: 789,
|
||||
value: ''
|
||||
key: 'system_settings',
|
||||
name: 'System Settings',
|
||||
value: { time_zone: '' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,41 @@ export const getVODStats = async () => {
|
|||
return await API.getVODStats();
|
||||
};
|
||||
|
||||
export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
|
||||
try {
|
||||
// Get all active channel IDs that have actual channels (not just streams)
|
||||
const activeChannelIds = Object.values(channelHistory)
|
||||
.filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id])
|
||||
.map(ch => channelsByUUID[ch.channel_id])
|
||||
.filter(id => id !== undefined);
|
||||
|
||||
if (activeChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const programs = await API.getCurrentPrograms(activeChannelIds);
|
||||
|
||||
// Convert array to map keyed by channel UUID for easy lookup
|
||||
const programsMap = {};
|
||||
if (programs && Array.isArray(programs)) {
|
||||
programs.forEach(program => {
|
||||
// Find the channel UUID from the channel ID
|
||||
const channelEntry = Object.entries(channelsByUUID).find(
|
||||
([uuid, id]) => id === program.channel_id
|
||||
);
|
||||
if (channelEntry) {
|
||||
programsMap[channelEntry[0]] = program;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return programsMap;
|
||||
} catch (error) {
|
||||
console.error('Error fetching current programs:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const getCombinedConnections = (channelHistory, vodConnections) => {
|
||||
const activeStreams = Object.values(channelHistory).map((channel) => ({
|
||||
type: 'stream',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue